Compare commits
254 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
26
angular.json
@@ -2,7 +2,8 @@
|
|||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
"cli": {
|
||||||
"packageManager": "npm"
|
"packageManager": "npm",
|
||||||
|
"analytics": false
|
||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
@@ -29,9 +30,7 @@
|
|||||||
"input": "public"
|
"input": "public"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": ["node_modules/leaflet/dist/leaflet.css", "src/styles.scss"],
|
||||||
"src/styles.scss"
|
|
||||||
],
|
|
||||||
"server": "src/main.server.ts",
|
"server": "src/main.server.ts",
|
||||||
"outputMode": "server",
|
"outputMode": "server",
|
||||||
"security": {
|
"security": {
|
||||||
@@ -58,9 +57,22 @@
|
|||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
|
"optimization": true,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"local": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"homo": {
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.homo.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "production"
|
"defaultConfiguration": "production"
|
||||||
@@ -73,6 +85,12 @@
|
|||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"buildTarget": "shopit-front:build:development"
|
"buildTarget": "shopit-front:build:development"
|
||||||
|
},
|
||||||
|
"local": {
|
||||||
|
"buildTarget": "shopit-front:build:local"
|
||||||
|
},
|
||||||
|
"homo": {
|
||||||
|
"buildTarget": "shopit-front:build:homo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "development"
|
||||||
|
|||||||
333
package-lock.json
generated
@@ -16,8 +16,11 @@
|
|||||||
"@angular/platform-server": "^22.0.0",
|
"@angular/platform-server": "^22.0.0",
|
||||||
"@angular/router": "^22.0.0",
|
"@angular/router": "^22.0.0",
|
||||||
"@angular/ssr": "^22.0.4",
|
"@angular/ssr": "^22.0.4",
|
||||||
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"qrcode": "1.5.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
"@angular/cli": "^22.0.4",
|
"@angular/cli": "^22.0.4",
|
||||||
"@angular/compiler-cli": "^22.0.0",
|
"@angular/compiler-cli": "^22.0.0",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^20.17.19",
|
"@types/node": "^20.17.19",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
@@ -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": {
|
"node_modules/@gar/promise-retry": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz",
|
||||||
@@ -3805,6 +3818,13 @@
|
|||||||
"@types/send": "*"
|
"@types/send": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/http-errors": {
|
"node_modules/@types/http-errors": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
@@ -3812,6 +3832,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.43",
|
"version": "20.19.43",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||||
@@ -4384,6 +4414,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001799",
|
"version": "1.0.30001799",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
||||||
@@ -4568,6 +4607,24 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
@@ -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": {
|
"node_modules/decimal.js": {
|
||||||
"version": "10.6.0",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
@@ -4776,6 +4842,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dom-serializer": {
|
"node_modules/dom-serializer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
@@ -5227,6 +5299,19 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -5296,7 +5381,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -5871,6 +5955,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/listr2": {
|
"node_modules/listr2": {
|
||||||
"version": "10.2.1",
|
"version": "10.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
|
||||||
@@ -5917,6 +6007,18 @@
|
|||||||
"@lmdb/lmdb-win32-x64": "3.5.4"
|
"@lmdb/lmdb-win32-x64": "3.5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/log-symbols": {
|
"node_modules/log-symbols": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
||||||
@@ -6693,6 +6795,33 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/p-map": {
|
"node_modules/p-map": {
|
||||||
"version": "7.0.4",
|
"version": "7.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
|
||||||
@@ -6706,6 +6835,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pacote": {
|
"node_modules/pacote": {
|
||||||
"version": "21.5.1",
|
"version": "21.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz",
|
||||||
@@ -6814,6 +6952,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-exists": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@@ -6911,6 +7058,15 @@
|
|||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
@@ -7023,6 +7179,154 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.2",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
@@ -7083,6 +7387,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-from-string": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@@ -7093,6 +7406,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/restore-cursor": {
|
"node_modules/restore-cursor": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||||
@@ -7315,6 +7634,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -8680,6 +9005,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/why-is-node-running": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
|||||||
@@ -20,8 +20,11 @@
|
|||||||
"@angular/platform-server": "^22.0.0",
|
"@angular/platform-server": "^22.0.0",
|
||||||
"@angular/router": "^22.0.0",
|
"@angular/router": "^22.0.0",
|
||||||
"@angular/ssr": "^22.0.4",
|
"@angular/ssr": "^22.0.4",
|
||||||
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"qrcode": "1.5.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -30,6 +33,7 @@
|
|||||||
"@angular/cli": "^22.0.4",
|
"@angular/cli": "^22.0.4",
|
||||||
"@angular/compiler-cli": "^22.0.0",
|
"@angular/compiler-cli": "^22.0.0",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^20.17.19",
|
"@types/node": "^20.17.19",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
|||||||
BIN
public/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/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/zapatillas-running.png
Normal file
|
After Width: | Height: | Size: 541 KiB |
71
src/_fonts.scss
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
@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";
|
||||||
|
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;
|
||||||
@@ -1,12 +1,40 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { HttpRequest, provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
ApplicationConfig,
|
||||||
|
provideAppInitializer,
|
||||||
|
provideBrowserGlobalErrorListeners,
|
||||||
|
} from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
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';
|
||||||
|
|
||||||
|
function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
|
||||||
|
return (
|
||||||
|
request.method === 'GET' &&
|
||||||
|
/\/api\/tenants\/[^/]+\/productos(?:\/\d+)?(?:\?|$)/.test(request.urlWithParams)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes), provideClientHydration()
|
provideRouter(routes),
|
||||||
]
|
provideClientHydration(
|
||||||
|
withHttpTransferCacheOptions({
|
||||||
|
includeRequestsWithAuthHeaders: true,
|
||||||
|
filter: isStoreCatalogRequest,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
provideHttpClient(
|
||||||
|
withFetch(),
|
||||||
|
withInterceptors([authInterceptor, globalLoadingInterceptor])
|
||||||
|
),
|
||||||
|
provideAppInitializer(authBootstrap),
|
||||||
|
provideAppInitializer(tenantBootstrap),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
362
src/app/app.html
@@ -1,344 +1,20 @@
|
|||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
@if (status() === 'ready') {
|
||||||
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
<router-outlet />
|
||||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
<app-toast-container />
|
||||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
<app-modal-host />
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
<app-global-loading />
|
||||||
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
} @else if (status() === 'not-found') {
|
||||||
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
<section class="tenant-status">
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
<div class="tenant-status__card">
|
||||||
|
<span class="tenant-status__code">404</span>
|
||||||
<style>
|
<h1 class="tenant-status__title">No encontramos una tienda para este dominio</h1>
|
||||||
:host {
|
<p class="tenant-status__description">
|
||||||
--bright-blue: oklch(51.01% 0.274 263.83);
|
Verifica la configuracion del tenant o intenta nuevamente con un dominio valido.
|
||||||
--electric-violet: oklch(53.18% 0.28 296.97);
|
</p>
|
||||||
--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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
</section>
|
||||||
<div class="right-side">
|
} @else {
|
||||||
<div class="pill-group">
|
<section class="tenant-status tenant-status--loading" aria-hidden="true">
|
||||||
@for (item of [
|
<div class="tenant-status__spinner"></div>
|
||||||
{ title: 'Explore the Docs', link: 'https://angular.dev' },
|
</section>
|
||||||
{ 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 />
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ import { RenderMode, ServerRoute } from '@angular/ssr';
|
|||||||
export const serverRoutes: ServerRoute[] = [
|
export const serverRoutes: ServerRoute[] = [
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
renderMode: RenderMode.Prerender
|
renderMode: RenderMode.Server
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
247
src/app/app.routes.spec.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
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 { 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}).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('redirects unauthenticated users from /checkout to /login', async () => {
|
||||||
|
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false));
|
||||||
|
|
||||||
|
expect(router.url).toBe('/login?returnUrl=%2Fcheckout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows authenticated users to access /checkout', async () => {
|
||||||
|
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(true));
|
||||||
|
|
||||||
|
expect(router.url).toBe('/checkout');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,18 @@
|
|||||||
import { Routes } from '@angular/router';
|
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)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|||||||
@@ -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 rgba(32, 32, 32, 0.1);
|
||||||
|
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 rgba(32, 32, 32, 0.08);
|
||||||
|
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,112 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import '@angular/compiler';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { TestBed, getTestBed } from '@angular/core/testing';
|
||||||
|
import {
|
||||||
|
BrowserTestingModule,
|
||||||
|
platformBrowserTesting
|
||||||
|
} from '@angular/platform-browser/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
|
import { Tenant } from './core/services/tenant.interface';
|
||||||
|
import { TenantService } from './core/services/tenant.service';
|
||||||
|
|
||||||
|
const tenant: Tenant = {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'test',
|
||||||
|
nombre: 'Test Tenant',
|
||||||
|
dominio: 'localhost',
|
||||||
|
primary_color: '#6376F3',
|
||||||
|
secondary_color: '#A0A0A0',
|
||||||
|
danger_color: '#FF8888',
|
||||||
|
success_color: '#198754',
|
||||||
|
header_bg_color: '#313131',
|
||||||
|
footer_bg_color: '#313131',
|
||||||
|
header_logo: 'https://example.com/header.png',
|
||||||
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
categories: []
|
||||||
|
};
|
||||||
|
|
||||||
|
function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {
|
||||||
|
const tenantState = signal(currentTenant);
|
||||||
|
const statusState = signal(status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenant: tenantState.asReadonly(),
|
||||||
|
status: statusState.asReadonly(),
|
||||||
|
getTenant: () => tenantState(),
|
||||||
|
bootstrap: vi.fn().mockResolvedValue(undefined)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
beforeEach(async () => {
|
beforeAll(() => {
|
||||||
|
try {
|
||||||
|
getTestBed().initTestEnvironment(
|
||||||
|
BrowserTestingModule,
|
||||||
|
platformBrowserTesting()
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Test environment may already be initialized by another setup entrypoint.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the app when the tenant is ready', async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: createTenantServiceStub('ready', tenant)
|
||||||
|
}
|
||||||
|
]
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance).toBeTruthy();
|
||||||
|
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary')).toBe(
|
||||||
|
tenant.primary_color
|
||||||
|
);
|
||||||
|
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary')).toBe(
|
||||||
|
tenant.secondary_color
|
||||||
|
);
|
||||||
|
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger')).toBe(
|
||||||
|
tenant.danger_color
|
||||||
|
);
|
||||||
|
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary-rgb')).toBe(
|
||||||
|
'99, 118, 243'
|
||||||
|
);
|
||||||
|
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary-rgb')).toBe(
|
||||||
|
'160, 160, 160'
|
||||||
|
);
|
||||||
|
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe(
|
||||||
|
'255, 136, 136'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create the app', () => {
|
it('renders the tenant not found screen when the tenant is missing', async () => {
|
||||||
const fixture = TestBed.createComponent(App);
|
await TestBed.configureTestingModule({
|
||||||
const app = fixture.componentInstance;
|
imports: [App],
|
||||||
expect(app).toBeTruthy();
|
providers: [
|
||||||
});
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: createTenantServiceStub('not-found', null)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
it('should render title', async () => {
|
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
await fixture.whenStable();
|
fixture.detectChanges();
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
|
||||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, shopit-front');
|
expect(fixture.nativeElement.textContent).toContain('No encontramos una tienda para este dominio');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,97 @@
|
|||||||
import { Component, signal } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
import { TenantService } from './core/services/tenant.service';
|
||||||
|
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
|
||||||
|
import { GlobalLoadingComponent } from './shared/components/global-loading/global-loading.component';
|
||||||
|
import { ModalHostComponent } from './shared/components/modal-host/modal-host.component';
|
||||||
|
import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component';
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): string {
|
||||||
|
const cleanHex = hex.replace('#', '').trim();
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (cleanHex.length === 3) {
|
||||||
|
r = parseInt(cleanHex[0] + cleanHex[0], 16);
|
||||||
|
g = parseInt(cleanHex[1] + cleanHex[1], 16);
|
||||||
|
b = parseInt(cleanHex[2] + cleanHex[2], 16);
|
||||||
|
} else if (cleanHex.length >= 6) {
|
||||||
|
r = parseInt(cleanHex.slice(0, 2), 16);
|
||||||
|
g = parseInt(cleanHex.slice(2, 4), 16);
|
||||||
|
b = parseInt(cleanHex.slice(4, 6), 16);
|
||||||
|
}
|
||||||
|
return `${r}, ${g}, ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet],
|
imports: [
|
||||||
|
RouterOutlet,
|
||||||
|
ToastContainerComponent,
|
||||||
|
ModalHostComponent,
|
||||||
|
GlobalLoadingComponent
|
||||||
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss',
|
||||||
|
host: {
|
||||||
|
'[style.--tenant-primary]': 'tenantPrimaryColor()',
|
||||||
|
'[style.--tenant-secondary]': 'tenantSecondaryColor()',
|
||||||
|
'[style.--tenant-danger]': 'tenantDangerColor()',
|
||||||
|
'[style.--tenant-success]': 'tenantSuccessColor()',
|
||||||
|
'[style.--color-primary]': 'tenantPrimaryColor()',
|
||||||
|
'[style.--color-secondary]': 'tenantSecondaryColor()',
|
||||||
|
'[style.--color-danger]': 'tenantDangerColor()',
|
||||||
|
'[style.--color-success]': 'tenantSuccessColor()',
|
||||||
|
'[style.--tenant-primary-rgb]': 'tenantPrimaryColorRgb()',
|
||||||
|
'[style.--tenant-secondary-rgb]': 'tenantSecondaryColorRgb()',
|
||||||
|
'[style.--tenant-danger-rgb]': 'tenantDangerColorRgb()',
|
||||||
|
'[style.--tenant-success-rgb]': 'tenantSuccessColorRgb()',
|
||||||
|
'[style.--color-primary-rgb]': 'tenantPrimaryColorRgb()',
|
||||||
|
'[style.--color-secondary-rgb]': 'tenantSecondaryColorRgb()',
|
||||||
|
'[style.--color-danger-rgb]': 'tenantDangerColorRgb()',
|
||||||
|
'[style.--color-success-rgb]': 'tenantSuccessColorRgb()',
|
||||||
|
'[style.--tenant-header-bg]': 'tenantHeaderBgColor()',
|
||||||
|
'[style.--tenant-footer-bg]': 'tenantFooterBgColor()',
|
||||||
|
'[style.--color-header-bg]': 'tenantHeaderBgColor()',
|
||||||
|
'[style.--color-footer-bg]': 'tenantFooterBgColor()'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
protected readonly title = signal('shopit-front');
|
private readonly tenantService = inject(TenantService);
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/app/core/guards/menu.guard.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { findMenu } from '../services/menu.utils';
|
||||||
|
import { TenantService } from '../services/tenant.service';
|
||||||
|
|
||||||
|
export const hasMenuGuard = (menuCode: string): CanActivateFn => {
|
||||||
|
return () => {
|
||||||
|
const tenantService = inject(TenantService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
const tenant = tenantService.tenant();
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
return router.createUrlTree(['/']);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined;
|
||||||
|
|
||||||
|
if (hasMenu) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return router.createUrlTree(['/']);
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,77 @@
|
|||||||
|
<footer class="store-layout__footer mt-auto text-light">
|
||||||
|
<div class="container-xl px-3 px-md-4 py-4 py-md-5">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<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">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<div class="d-flex align-items-center gap-2 ">
|
||||||
|
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>
|
||||||
|
<span>Av. San Lorenzo 1542, Rosario</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 ">
|
||||||
|
<i class="fa-solid fa-phone" aria-hidden="true"></i>
|
||||||
|
<span>54 9 (0341) 6658247</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mb-0 small store-layout__muted-text">Copyright © {{ currentYear }} - Sonder</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@for (section of footerSections; track section.heading) {
|
||||||
|
<div class="col-12 col-md-6 col-xl-2 d-grid align-content-start">
|
||||||
|
<div class="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="col-12 col-md-6 col-xl-3 d-grid align-content-start">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
@for (social of 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,102 @@
|
|||||||
|
.store-layout__footer {
|
||||||
|
background-color: var(--tenant-footer-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__brand-slot {
|
||||||
|
width: min(100%, 12rem);
|
||||||
|
min-width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__brand-logo-shell {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, 12rem);
|
||||||
|
height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 2.75rem;
|
||||||
|
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 rgba(255, 255, 255, 0.35);
|
||||||
|
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: rgba(255, 255, 255, 0.72);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__footer-action {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes store-footer-logo-skeleton {
|
||||||
|
from {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.store-layout__brand-slot {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__brand-text {
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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;
|
||||||
|
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,126 @@
|
|||||||
|
<header class="store-layout__header">
|
||||||
|
<div class="store-layout__header-main">
|
||||||
|
<div
|
||||||
|
class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-row flex-wrap align-items-center justify-content-between gap-3"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
routerLink="/"
|
||||||
|
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0 text-decoration-none"
|
||||||
|
data-testid="store-header-brand-slot"
|
||||||
|
aria-label="Logo del comercio"
|
||||||
|
>
|
||||||
|
@if (logoUrl()) {
|
||||||
|
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
|
||||||
|
} @else {
|
||||||
|
<div class="store-layout__brand-logo-shell" aria-hidden="true">
|
||||||
|
<span class="store-layout__brand-logo-skeleton"></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap flex-grow-1 justify-content-between justify-content-md-end"
|
||||||
|
>
|
||||||
|
<div class="store-layout__search-wrapper">
|
||||||
|
<div class="input-group store-layout__search" role="search">
|
||||||
|
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
|
||||||
|
<input
|
||||||
|
id="store-search-input"
|
||||||
|
type="search"
|
||||||
|
class="form-control store-layout__search-input border-end-0 rounded-start"
|
||||||
|
placeholder="Buscar productos"
|
||||||
|
autocomplete="off"
|
||||||
|
[attr.aria-describedby]="showSearchError() ? 'store-search-error' : null"
|
||||||
|
[attr.aria-invalid]="showSearchError()"
|
||||||
|
[minlength]="minSearchLength"
|
||||||
|
[formControl]="searchControl"
|
||||||
|
(input)="clearSearchError()"
|
||||||
|
(keydown.enter)="submitSearch($event)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn store-layout__search-button border-start-0 rounded-end px-3"
|
||||||
|
aria-label="Buscar"
|
||||||
|
(click)="submitSearch()"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showSearchError()) {
|
||||||
|
<p id="store-search-error" class="store-layout__search-error mb-0" role="alert">
|
||||||
|
Ingresá al menos {{ minSearchLength }} caracteres para buscar.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (ticketsMenu(); as menu) {
|
||||||
|
<app-button
|
||||||
|
variant="primary"
|
||||||
|
hostClass="flex-shrink-0"
|
||||||
|
data-testid="store-header-tickets"
|
||||||
|
(click)="ticketsClick.emit()"
|
||||||
|
>
|
||||||
|
{{ menu.label }}
|
||||||
|
</app-button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="d-flex align-items-center justify-content-end gap-1"
|
||||||
|
aria-label="Acciones de usuario"
|
||||||
|
>
|
||||||
|
<app-cart-icon
|
||||||
|
[quantity]="cartQuantity()"
|
||||||
|
ariaLabel="Carrito de compras"
|
||||||
|
title="Carrito"
|
||||||
|
(click)="cartClick.emit()"
|
||||||
|
/>
|
||||||
|
<div class="store-layout__user-menu">
|
||||||
|
<app-icon-button
|
||||||
|
variant="user"
|
||||||
|
ariaLabel="Mi cuenta"
|
||||||
|
ariaControls="store-user-dropdown"
|
||||||
|
[ariaExpanded]="isUserDropdownOpen()"
|
||||||
|
title="Mi cuenta"
|
||||||
|
(clicked)="toggleUserDropdown()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (isUserDropdownOpen()) {
|
||||||
|
<app-user-dropdown
|
||||||
|
id="store-user-dropdown"
|
||||||
|
[user]="user()"
|
||||||
|
[accountNavigationMenus]="accountNavigationMenus()"
|
||||||
|
(logoutClick)="onLogoutClick()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="store-layout__categories">
|
||||||
|
<div class="container-xl px-3 px-md-4">
|
||||||
|
<div class="store-layout__category-menu">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-link d-inline-flex align-items-center gap-2 px-0 py-2 text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
|
||||||
|
aria-label="Categorias"
|
||||||
|
aria-controls="store-category-dropdown"
|
||||||
|
[attr.aria-expanded]="isCategoryDropdownOpen()"
|
||||||
|
(click)="toggleCategoryDropdown()"
|
||||||
|
>
|
||||||
|
<span>Categorias</span>
|
||||||
|
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<app-category-dropdown
|
||||||
|
id="store-category-dropdown"
|
||||||
|
[categories]="categories()"
|
||||||
|
[open]="isCategoryDropdownOpen()"
|
||||||
|
(categorySelect)="onCategorySelect($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
.store-layout__header {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: var(--tenant-header-bg);
|
||||||
|
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__header-main {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__brand-slot {
|
||||||
|
min-width: 150px;
|
||||||
|
max-width:40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: min(100%, 260px);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__search-error {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--tenant-danger, #dc3545);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__search-input {
|
||||||
|
color: #666666;
|
||||||
|
border-color: #cccccc;
|
||||||
|
background-color: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__search-input::placeholder {
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__search-input:focus {
|
||||||
|
border-color: #cccccc;
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__search-button {
|
||||||
|
color: #a0a0a0;
|
||||||
|
border-color: #cccccc;
|
||||||
|
background-color: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__search-button:hover,
|
||||||
|
.store-layout__search-button:focus-visible {
|
||||||
|
color: #8a8a8a;
|
||||||
|
border-color: #cccccc;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { AuthUser } from '../../../services/auth/auth.interfaces';
|
||||||
|
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
|
||||||
|
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
|
||||||
|
import { UserDropdownComponent } from './user-dropdown/user-dropdown.component';
|
||||||
|
import { Menu } from '../../../services/tenant.interface';
|
||||||
|
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||||
|
import { ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||||
|
import { Category } from '../../../services/tenant.interface';
|
||||||
|
import { CategoryDropdownComponent } from './category-dropdown/category-dropdown.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-store-header',
|
||||||
|
imports: [
|
||||||
|
ButtonComponent,
|
||||||
|
CartIconComponent,
|
||||||
|
IconButtonComponent,
|
||||||
|
RouterLink,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
UserDropdownComponent,
|
||||||
|
CategoryDropdownComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './store-header.component.html',
|
||||||
|
styleUrl: './store-header.component.scss',
|
||||||
|
})
|
||||||
|
export class StoreHeaderComponent {
|
||||||
|
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||||
|
|
||||||
|
readonly logoUrl = input<string | null>(null);
|
||||||
|
readonly cartQuantity = input<number>(0);
|
||||||
|
readonly user = input<AuthUser | null>(null);
|
||||||
|
readonly isAuthenticated = input<boolean>(false);
|
||||||
|
readonly accountNavigationMenus = input<readonly Menu[]>([]);
|
||||||
|
readonly ticketsMenu = input<Menu | null>(null);
|
||||||
|
readonly categories = input<readonly Category[]>([]);
|
||||||
|
readonly cartClick = output<void>();
|
||||||
|
readonly ticketsClick = output<void>();
|
||||||
|
readonly loginClick = output<void>();
|
||||||
|
readonly logoutClick = output<void>();
|
||||||
|
readonly searchSubmit = output<string>();
|
||||||
|
readonly categorySelect = output<Category>();
|
||||||
|
|
||||||
|
protected readonly isUserDropdownOpen = signal(false);
|
||||||
|
protected readonly isCategoryDropdownOpen = signal(false);
|
||||||
|
protected readonly minSearchLength = 3;
|
||||||
|
protected readonly showSearchError = signal(false);
|
||||||
|
protected readonly searchControl = new FormControl('', { nonNullable: true });
|
||||||
|
|
||||||
|
@HostListener('document:click', ['$event'])
|
||||||
|
protected onDocumentClick(event: MouseEvent): void {
|
||||||
|
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
|
||||||
|
this.isUserDropdownOpen.set(false);
|
||||||
|
this.isCategoryDropdownOpen.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
protected onEscapeKey(): void {
|
||||||
|
this.isUserDropdownOpen.set(false);
|
||||||
|
this.isCategoryDropdownOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggleUserDropdown(): void {
|
||||||
|
if (!this.isAuthenticated()) {
|
||||||
|
this.loginClick.emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isUserDropdownOpen.update((isOpen) => !isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onLoginClick(): void {
|
||||||
|
this.isUserDropdownOpen.set(false);
|
||||||
|
this.loginClick.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onLogoutClick(): void {
|
||||||
|
this.isUserDropdownOpen.set(false);
|
||||||
|
this.logoutClick.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected submitSearch(event?: Event): void {
|
||||||
|
event?.preventDefault();
|
||||||
|
|
||||||
|
const term = this.searchControl.value.trim();
|
||||||
|
|
||||||
|
if (term.length < this.minSearchLength) {
|
||||||
|
this.showSearchError.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showSearchError.set(false);
|
||||||
|
this.searchSubmit.emit(term);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected clearSearchError(): void {
|
||||||
|
this.showSearchError.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggleCategoryDropdown(): void {
|
||||||
|
this.isCategoryDropdownOpen.update((isOpen) => !isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onCategorySelect(category: Category): void {
|
||||||
|
this.isCategoryDropdownOpen.set(false);
|
||||||
|
this.categorySelect.emit(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<section class="user-dropdown" role="menu" aria-label="Menu de usuario">
|
||||||
|
<div class="user-dropdown__profile">
|
||||||
|
<span class="user-dropdown__name">{{ displayName() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="user-dropdown__divider"></div>
|
||||||
|
|
||||||
|
@for (menu of accountNavigationMenus(); track menu.code) {
|
||||||
|
<a
|
||||||
|
[routerLink]="menu.route"
|
||||||
|
class="user-dropdown__item"
|
||||||
|
role="menuitem"
|
||||||
|
[attr.data-testid]="'user-dropdown-' + menu.code"
|
||||||
|
>
|
||||||
|
{{ menu.label }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="user-dropdown__item"
|
||||||
|
role="menuitem"
|
||||||
|
data-testid="user-dropdown-logout"
|
||||||
|
(click)="logoutClick.emit()"
|
||||||
|
>
|
||||||
|
Cerrar sesion
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
:host {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.75rem);
|
||||||
|
right: 0;
|
||||||
|
z-index: 1060;
|
||||||
|
display: block;
|
||||||
|
width: min(18rem, calc(100vw - 2rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown {
|
||||||
|
display: grid;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(32, 32, 32, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.16);
|
||||||
|
padding: 0.75rem;
|
||||||
|
animation: user-dropdown-enter 0.16s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown__profile {
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown__name {
|
||||||
|
color: #a0a0a0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 325;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown__divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
background: rgba(32, 32, 32, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown__item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.65rem 0.6rem;
|
||||||
|
color: #8a8a8a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown__item:active,
|
||||||
|
.user-dropdown__item:hover,
|
||||||
|
.user-dropdown__item:focus-visible {
|
||||||
|
color: var(--tenant-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes user-dropdown-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-0.35rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
:host {
|
||||||
|
right: -0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
66
src/app/core/layout/store-layout/store-layout.component.html
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<div class="store-layout d-flex flex-column flex-grow-1">
|
||||||
|
<app-store-header
|
||||||
|
[cartQuantity]="cartQuantity()"
|
||||||
|
[logoUrl]="tenant()?.header_logo ?? null"
|
||||||
|
[user]="user()"
|
||||||
|
[isAuthenticated]="isAuthenticated()"
|
||||||
|
[accountNavigationMenus]="accountNavigationMenus()"
|
||||||
|
[ticketsMenu]="ticketsMenu() ?? null"
|
||||||
|
[categories]="tenant()?.categories ?? []"
|
||||||
|
(cartClick)="isCartOpen.set(!isCartOpen())"
|
||||||
|
(ticketsClick)="onTicketsClick()"
|
||||||
|
(loginClick)="onLoginClick()"
|
||||||
|
(logoutClick)="onLogoutClick()"
|
||||||
|
(searchSubmit)="onSearch($event)"
|
||||||
|
(categorySelect)="onCategorySelect($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (isCartOpen()) {
|
||||||
|
<div
|
||||||
|
class="store-layout__cart-overlay"
|
||||||
|
(click)="isCartOpen.set(false)"
|
||||||
|
></div>
|
||||||
|
<div class="store-layout__cart-dropdown card border-0 shadow-lg">
|
||||||
|
<app-cart
|
||||||
|
[showClose]="true"
|
||||||
|
[items]="mappedCartItems()"
|
||||||
|
[subtotal]="cartSubtotal()"
|
||||||
|
[discount]="cartDiscount()"
|
||||||
|
[total]="cartTotal()"
|
||||||
|
[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"
|
||||||
|
(logoutClick)="onLogoutClick()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
55
src/app/core/layout/store-layout/store-layout.component.scss
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #202020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__cart-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 1040;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-layout__cart-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 76px;
|
||||||
|
right: calc((100% - 1320px) / 2 + 1.5rem);
|
||||||
|
|
||||||
|
max-height: calc(100vh - 120px);
|
||||||
|
z-index: 1050;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: store-layout-slide-down 0.2s ease-out;
|
||||||
|
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
right: 1.5rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
right: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes store-layout-slide-down {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
611
src/app/core/layout/store-layout/store-layout.component.spec.ts
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { provideRouter, Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { Tenant } from '../../services/tenant.interface';
|
||||||
|
import { TenantService } from '../../services/tenant.service';
|
||||||
|
import { CartService } from '../../services/cart/cart.service';
|
||||||
|
import { Cart } from '../../services/cart/cart.interface';
|
||||||
|
import { ToastService } from '../../services/toast.service';
|
||||||
|
import { AuthService } from '../../services/auth/auth.service';
|
||||||
|
import { AuthUser } from '../../services/auth/auth.interfaces';
|
||||||
|
import { StoreLayoutComponent } from './store-layout.component';
|
||||||
|
import { StoreHeaderComponent } from './store-header/store-header.component';
|
||||||
|
import { CheckoutService } from '../../services/checkout.service';
|
||||||
|
|
||||||
|
const tenant: Tenant = {
|
||||||
|
id: 1,
|
||||||
|
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: 4,
|
||||||
|
code: 'account',
|
||||||
|
label: 'Mi cuenta',
|
||||||
|
parent_menu_code: null,
|
||||||
|
content_type: 'dynamic',
|
||||||
|
route: '/mi-cuenta',
|
||||||
|
submenues: [
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
code: 'account.profile',
|
||||||
|
label: 'Datos personales',
|
||||||
|
parent_menu_code: 'account',
|
||||||
|
content_type: 'dynamic',
|
||||||
|
route: '/mi-cuenta/datos-personales',
|
||||||
|
submenues: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
code: 'account.purchases',
|
||||||
|
label: 'Mis compras',
|
||||||
|
parent_menu_code: 'account',
|
||||||
|
content_type: 'dynamic',
|
||||||
|
route: '/mi-cuenta/compras',
|
||||||
|
submenues: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
code: 'account.tickets',
|
||||||
|
label: 'Mis tickets',
|
||||||
|
parent_menu_code: 'account',
|
||||||
|
content_type: 'dynamic',
|
||||||
|
route: '/mi-cuenta/tickets',
|
||||||
|
submenues: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
code: 'help',
|
||||||
|
label: 'Ayuda',
|
||||||
|
parent_menu_code: null,
|
||||||
|
content_type: 'dynamic',
|
||||||
|
route: '/ayuda',
|
||||||
|
submenues: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
code: 'help.contact',
|
||||||
|
label: 'Contacto',
|
||||||
|
parent_menu_code: 'help',
|
||||||
|
content_type: 'static',
|
||||||
|
route: '/ayuda/contacto',
|
||||||
|
submenues: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
code: 'help.faq',
|
||||||
|
label: 'Preguntas frecuentes',
|
||||||
|
parent_menu_code: 'help',
|
||||||
|
content_type: 'static',
|
||||||
|
route: '/ayuda/preguntas-frecuentes',
|
||||||
|
submenues: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
social_media: [
|
||||||
|
{
|
||||||
|
code: 'instagram',
|
||||||
|
icon: 'instagram',
|
||||||
|
name: 'Instagram',
|
||||||
|
url: 'https://instagram.com/test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'whatsapp',
|
||||||
|
icon: 'whatsapp',
|
||||||
|
name: 'WhatsApp',
|
||||||
|
url: 'https://wa.me/5493410000000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'facebook',
|
||||||
|
icon: 'facebook',
|
||||||
|
name: 'Facebook',
|
||||||
|
url: 'https://facebook.com/test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'linkedin',
|
||||||
|
icon: 'fa-brands fa-linkedin-in',
|
||||||
|
name: 'LinkedIn',
|
||||||
|
url: 'https://linkedin.com/company/test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('StoreLayoutComponent', () => {
|
||||||
|
let tenantState = signal<Tenant | null>(tenant);
|
||||||
|
let cartState = signal<Cart | null>(null);
|
||||||
|
let authUserState = signal<AuthUser | null>(null);
|
||||||
|
let checkoutServiceStub: { startCheckout: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tenantState = signal<Tenant | null>(tenant);
|
||||||
|
cartState = signal<Cart | null>(null);
|
||||||
|
authUserState = signal<AuthUser | null>(null);
|
||||||
|
const isAuthenticatedState = signal(false);
|
||||||
|
checkoutServiceStub = {
|
||||||
|
startCheckout: vi.fn().mockResolvedValue({ id: 55 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [StoreLayoutComponent],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: {
|
||||||
|
tenant: tenantState.asReadonly(),
|
||||||
|
status: signal<'ready'>('ready').asReadonly(),
|
||||||
|
getTenant: () => tenantState(),
|
||||||
|
bootstrap: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CartService,
|
||||||
|
useValue: {
|
||||||
|
cart: cartState.asReadonly(),
|
||||||
|
loadCart: () => of({ id: 1, items: [], subtotal: '0' }),
|
||||||
|
updateItemQuantity: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
|
||||||
|
removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
|
||||||
|
clearCart: vi.fn(() => cartState.set(null)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: {
|
||||||
|
show: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
danger: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: AuthService,
|
||||||
|
useValue: {
|
||||||
|
user: authUserState,
|
||||||
|
isAuthenticated: isAuthenticatedState,
|
||||||
|
logout: vi.fn().mockReturnValue(of(void 0)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CheckoutService,
|
||||||
|
useValue: checkoutServiceStub,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the main layout sections', () => {
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(compiled.querySelector('app-store-header .store-layout__header')).not.toBeNull();
|
||||||
|
expect(compiled.querySelector('.store-layout__category-trigger')).not.toBeNull();
|
||||||
|
expect(compiled.querySelector('router-outlet')).not.toBeNull();
|
||||||
|
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the tenant branding and action icons', () => {
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull();
|
||||||
|
expect(compiled.querySelector('[data-testid="store-footer-brand-slot"]')).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
compiled.querySelector(
|
||||||
|
`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`,
|
||||||
|
),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
compiled.querySelector(
|
||||||
|
`app-store-footer img.store-layout__brand-logo[src="${tenant.footer_logo}"]`,
|
||||||
|
),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(compiled.querySelector('.fa-cart-shopping')).not.toBeNull();
|
||||||
|
expect(compiled.querySelector('.fa-circle-user')).not.toBeNull();
|
||||||
|
expect(compiled.querySelector('.fa-instagram')).not.toBeNull();
|
||||||
|
expect(compiled.querySelector('.fa-whatsapp')).not.toBeNull();
|
||||||
|
expect(compiled.querySelector('.fa-facebook')).not.toBeNull();
|
||||||
|
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to search results when the search button is clicked', () => {
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
vi.spyOn(router, 'navigate');
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
||||||
|
const searchButton = header.query(By.css('.store-layout__search-button'));
|
||||||
|
|
||||||
|
(header.componentInstance as any).searchControl.setValue(' zapatillas ');
|
||||||
|
searchButton.triggerEventHandler('click');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/buscar'], {
|
||||||
|
queryParams: { q: 'zapatillas', page: 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents the native submit and searches when Enter is pressed', () => {
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
vi.spyOn(router, 'navigate');
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
||||||
|
const input = header.query(By.css('#store-search-input'));
|
||||||
|
const keyboardEvent = {
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(header.componentInstance as any).searchControl.setValue('remera');
|
||||||
|
input.triggerEventHandler('keydown.enter', keyboardEvent);
|
||||||
|
|
||||||
|
expect(keyboardEvent.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/buscar'], {
|
||||||
|
queryParams: { q: 'remera', page: 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires at least three characters to search', () => {
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
vi.spyOn(router, 'navigate');
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
||||||
|
const input = header.query(By.css('#store-search-input'));
|
||||||
|
const searchButton = header.query(By.css('.store-layout__search-button'));
|
||||||
|
|
||||||
|
expect(input.nativeElement.getAttribute('minlength')).toBe('3');
|
||||||
|
|
||||||
|
(header.componentInstance as any).searchControl.setValue('ab');
|
||||||
|
searchButton.triggerEventHandler('click');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(router.navigate).not.toHaveBeenCalled();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
|
||||||
|
'Ingresá al menos 3 caracteres para buscar.',
|
||||||
|
);
|
||||||
|
expect(input.nativeElement.getAttribute('aria-invalid')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the primary tickets action when the tenant has the tickets menu', () => {
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
vi.spyOn(router, 'navigate');
|
||||||
|
const configuredTicketsLabel = 'Entradas digitales';
|
||||||
|
tenantState.set({
|
||||||
|
...tenant,
|
||||||
|
menues: tenant.menues?.map((menu) =>
|
||||||
|
menu.code === 'account'
|
||||||
|
? {
|
||||||
|
...menu,
|
||||||
|
submenues: menu.submenues.map((submenu) =>
|
||||||
|
submenu.code === 'account.tickets'
|
||||||
|
? { ...submenu, label: configuredTicketsLabel }
|
||||||
|
: submenu,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: menu,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const ticketsAction = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>(
|
||||||
|
'app-button[data-testid="store-header-tickets"]',
|
||||||
|
);
|
||||||
|
const ticketsButton = ticketsAction?.querySelector('button');
|
||||||
|
|
||||||
|
expect(ticketsAction).not.toBeNull();
|
||||||
|
expect(ticketsButton).not.toBeNull();
|
||||||
|
expect(ticketsButton?.textContent?.trim()).toBe(configuredTicketsLabel);
|
||||||
|
expect(ticketsButton?.classList).toContain('btn-primary');
|
||||||
|
|
||||||
|
ticketsButton?.click();
|
||||||
|
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/mi-cuenta/tickets']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render the tickets action when the tenant lacks the tickets menu', () => {
|
||||||
|
tenantState.set({
|
||||||
|
...tenant,
|
||||||
|
menues: tenant.menues?.map((menu) =>
|
||||||
|
menu.code === 'account'
|
||||||
|
? {
|
||||||
|
...menu,
|
||||||
|
submenues: menu.submenues.filter((submenu) => submenu.code !== 'account.tickets'),
|
||||||
|
}
|
||||||
|
: menu,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(fixture.nativeElement as HTMLElement).querySelector('[data-testid="store-header-tickets"]'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to /login when user icon is clicked and user is not logged in', () => {
|
||||||
|
const authService = TestBed.inject(AuthService);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
vi.spyOn(router, 'navigate');
|
||||||
|
|
||||||
|
(authService.isAuthenticated as any).set(false);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
const userButton = compiled.querySelector(
|
||||||
|
'app-icon-button[variant="user"] button',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(userButton).not.toBeNull();
|
||||||
|
userButton.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(compiled.querySelector('app-user-dropdown')).toBeNull();
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/login']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens authenticated user dropdown without redirecting on icon click', () => {
|
||||||
|
const authService = TestBed.inject(AuthService);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
vi.spyOn(router, 'navigate');
|
||||||
|
|
||||||
|
(authService.isAuthenticated as any).set(true);
|
||||||
|
(authService.user as any).set({
|
||||||
|
id: 1,
|
||||||
|
nombre_apellido: 'Ada Lovelace',
|
||||||
|
email: 'ada@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
const userButton = compiled.querySelector(
|
||||||
|
'app-icon-button[variant="user"] button',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(userButton).not.toBeNull();
|
||||||
|
userButton.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(compiled.querySelector('app-user-dropdown')).not.toBeNull();
|
||||||
|
expect(compiled.textContent).toContain('Ada Lovelace');
|
||||||
|
expect(compiled.querySelector('[data-testid="user-dropdown-account"]')).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
compiled.querySelector('[data-testid="user-dropdown-account.purchases"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(compiled.querySelector('[data-testid="user-dropdown-account.tickets"]')).toBeNull();
|
||||||
|
expect(router.navigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs out from the authenticated user dropdown, clears the cart and returns home', () => {
|
||||||
|
const authService = TestBed.inject(AuthService);
|
||||||
|
const cartService = TestBed.inject(CartService);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
vi.spyOn(router, 'navigate');
|
||||||
|
|
||||||
|
(authService.isAuthenticated as any).set(true);
|
||||||
|
(authService.user as any).set({
|
||||||
|
id: 1,
|
||||||
|
nombre_apellido: 'Ada Lovelace',
|
||||||
|
email: 'ada@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
const userButton = compiled.querySelector(
|
||||||
|
'app-icon-button[variant="user"] button',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
userButton.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const logoutButton = compiled.querySelector(
|
||||||
|
'[data-testid="user-dropdown-logout"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(logoutButton).not.toBeNull();
|
||||||
|
logoutButton.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(authService.logout).toHaveBeenCalled();
|
||||||
|
expect(cartService.clearCart).toHaveBeenCalled();
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides account actions from the footer', () => {
|
||||||
|
const authService = TestBed.inject(AuthService);
|
||||||
|
const cartService = TestBed.inject(CartService);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
vi.spyOn(router, 'navigate');
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
const footerLinks = Array.from(
|
||||||
|
compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'),
|
||||||
|
);
|
||||||
|
const profileLink = footerLinks.find((link) => link.textContent?.trim() === 'Mi cuenta');
|
||||||
|
const purchasesLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis compras');
|
||||||
|
const ticketsLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis tickets');
|
||||||
|
const logoutButton = Array.from(
|
||||||
|
compiled.querySelectorAll<HTMLButtonElement>('app-store-footer button'),
|
||||||
|
).find((button) => button.textContent?.trim() === 'Cerrar sesion');
|
||||||
|
|
||||||
|
expect(profileLink?.getAttribute('href')).toBe('/mi-cuenta/datos-personales');
|
||||||
|
expect(purchasesLink?.getAttribute('href')).toBe('/mi-cuenta/compras');
|
||||||
|
expect(ticketsLink).toBeUndefined();
|
||||||
|
expect(logoutButton).toBeDefined();
|
||||||
|
|
||||||
|
logoutButton!.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(authService.logout).toHaveBeenCalled();
|
||||||
|
expect(cartService.clearCart).toHaveBeenCalled();
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves checkout before logging out so the pending purchase can be cancelled', async () => {
|
||||||
|
const authService = TestBed.inject(AuthService);
|
||||||
|
const cartService = TestBed.inject(CartService);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
vi.spyOn(router, 'url', 'get').mockReturnValue('/checkout?purchase=25');
|
||||||
|
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
await (fixture.componentInstance as any).onLogoutClick();
|
||||||
|
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith(['/']);
|
||||||
|
expect(authService.logout).toHaveBeenCalled();
|
||||||
|
expect(navigateSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
(authService.logout as any).mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
expect(cartService.clearCart).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders only the help submenus assigned to the tenant in the footer', () => {
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
const footerLinks = Array.from(
|
||||||
|
compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'),
|
||||||
|
);
|
||||||
|
const helpLinks = footerLinks.filter((link) =>
|
||||||
|
link.getAttribute('href')?.startsWith('/ayuda/'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(helpLinks.map((link) => link.textContent?.trim())).toEqual([
|
||||||
|
'Contacto',
|
||||||
|
'Preguntas frecuentes',
|
||||||
|
]);
|
||||||
|
expect(helpLinks.map((link) => link.getAttribute('href'))).toEqual([
|
||||||
|
'/ayuda/contacto',
|
||||||
|
'/ayuda/preguntas-frecuentes',
|
||||||
|
]);
|
||||||
|
expect(compiled.textContent).not.toContain('Medios de pago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a purchase and redirects to checkout when cart buy is clicked', async () => {
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
vi.spyOn(router, 'navigate');
|
||||||
|
cartState.set({
|
||||||
|
id: 1,
|
||||||
|
tenant_codigo: tenant.codigo,
|
||||||
|
status: 'active',
|
||||||
|
subtotal: '100.00',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
cantidad: 1,
|
||||||
|
precio_unitario: '100.00',
|
||||||
|
catalog_item_id: 1,
|
||||||
|
variant_id: null,
|
||||||
|
product: {
|
||||||
|
nombre: 'Producto',
|
||||||
|
imagen: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
authUserState.set({
|
||||||
|
id: 7,
|
||||||
|
nombre_apellido: 'Juan Perez',
|
||||||
|
email: 'juan@example.com',
|
||||||
|
dni: '12345678',
|
||||||
|
telefono: '3415555555',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
(fixture.componentInstance as any).isCartOpen.set(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
const buyButton = Array.from(compiled.querySelectorAll('app-button button')).find(
|
||||||
|
(button) => button.textContent?.trim() === 'Comprar',
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
|
||||||
|
expect(buyButton).toBeDefined();
|
||||||
|
buyButton!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(checkoutServiceStub.startCheckout).toHaveBeenCalledWith('test', {
|
||||||
|
cart_id: 1,
|
||||||
|
});
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/checkout'], {
|
||||||
|
queryParams: { purchase: 55 },
|
||||||
|
});
|
||||||
|
expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows modifying quantities directly in the regular cart without a toggle', () => {
|
||||||
|
cartState.set({
|
||||||
|
id: 1,
|
||||||
|
tenant_codigo: tenant.codigo,
|
||||||
|
status: 'active',
|
||||||
|
subtotal: '100.00',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
cantidad: 1,
|
||||||
|
precio_unitario: '100.00',
|
||||||
|
catalog_item_id: 1,
|
||||||
|
variant_id: null,
|
||||||
|
product: {
|
||||||
|
nombre: 'Producto',
|
||||||
|
imagen: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.componentInstance as any).isCartOpen.set(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const cartItem = fixture.debugElement.query(By.css('app-cart-item'));
|
||||||
|
const buyButton = Array.from(
|
||||||
|
element.querySelectorAll<HTMLButtonElement>('app-button button'),
|
||||||
|
).find((button) => button.textContent?.trim() === 'Comprar');
|
||||||
|
|
||||||
|
expect(element.querySelector('.cart-edit-btn')).toBeNull();
|
||||||
|
expect(cartItem.componentInstance.quantityDisabled()).toBe(false);
|
||||||
|
expect(buyButton).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
231
src/app/core/layout/store-layout/store-layout.component.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||||
|
import { Router, RouterOutlet } from '@angular/router';
|
||||||
|
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 } from '../../services/cart/cart.interface';
|
||||||
|
import { AuthService } from '../../services/auth/auth.service';
|
||||||
|
import { findMenu } from '../../services/menu.utils';
|
||||||
|
import { CheckoutService } from '../../services/checkout.service';
|
||||||
|
import { ToastService } from '../../services/toast.service';
|
||||||
|
import { Category } from '../../services/tenant.interface';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-store-layout',
|
||||||
|
imports: [
|
||||||
|
RouterOutlet,
|
||||||
|
StoreHeaderComponent,
|
||||||
|
StoreFooterComponent,
|
||||||
|
CartComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
],
|
||||||
|
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 toastService = inject(ToastService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
protected readonly isCartOpen = signal(false);
|
||||||
|
protected readonly isCreatingPurchase = signal(false);
|
||||||
|
|
||||||
|
protected readonly cartSubtotal = computed(() => {
|
||||||
|
const cart = this.cartService.cart();
|
||||||
|
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.product?.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() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cartItemId: item.id,
|
||||||
|
imageUrl: item.product?.imagen ?? null,
|
||||||
|
product,
|
||||||
|
originalPrice: null,
|
||||||
|
discountedPrice: parseFloat(item.precio_unitario),
|
||||||
|
discountPercentage: null,
|
||||||
|
attributes,
|
||||||
|
quantity: item.cantidad,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly currentYear = new Date().getFullYear();
|
||||||
|
protected readonly tenant = this.tenantService.tenant;
|
||||||
|
protected readonly user = this.authService.user;
|
||||||
|
protected readonly isAuthenticated = this.authService.isAuthenticated;
|
||||||
|
protected readonly ticketsMenu = computed(() =>
|
||||||
|
findMenu(this.tenant()?.menues ?? [], 'account.tickets'),
|
||||||
|
);
|
||||||
|
protected readonly accountNavigationMenus = computed(() => {
|
||||||
|
const accountMenu = findMenu(this.tenant()?.menues ?? [], 'account');
|
||||||
|
|
||||||
|
if (!accountMenu) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = accountMenu.submenues.find((menu) => menu.code === 'account.profile');
|
||||||
|
const purchases = accountMenu.submenues.find((menu) => menu.code === 'account.purchases');
|
||||||
|
|
||||||
|
return [
|
||||||
|
...(profile ? [{ ...accountMenu, route: profile.route }] : []),
|
||||||
|
...(purchases ? [purchases] : []),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly cartQuantity = computed(() => {
|
||||||
|
const cart = this.cartService.cart();
|
||||||
|
if (!cart || !cart.items) return 0;
|
||||||
|
return cart.items.reduce((total, item) => total + item.cantidad, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.cartService.loadCart().subscribe({
|
||||||
|
error: (err) => console.error('Error loading cart', err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onLoginClick(): void {
|
||||||
|
void this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSearch(term: string): void {
|
||||||
|
void this.router.navigate(['/buscar'], {
|
||||||
|
queryParams: { q: term, page: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onCategorySelect(category: Category): void {
|
||||||
|
void this.router.navigate(['/categoria', category.id], {
|
||||||
|
queryParams: { page: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onTicketsClick(): void {
|
||||||
|
const route = this.ticketsMenu()?.route;
|
||||||
|
|
||||||
|
if (route) {
|
||||||
|
void this.router.navigate([route]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onLogoutClick(): Promise<void> {
|
||||||
|
const isLeavingCheckout = this.router.url.startsWith('/checkout');
|
||||||
|
|
||||||
|
// Checkout must be left while the authenticated session is still valid so
|
||||||
|
// its CanDeactivate guard can cancel the pending purchase.
|
||||||
|
if (isLeavingCheckout) {
|
||||||
|
const navigationSucceeded = await this.router.navigate(['/']);
|
||||||
|
|
||||||
|
if (!navigationSucceeded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.authService.logout().subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.cartService.clearCart();
|
||||||
|
this.isCartOpen.set(false);
|
||||||
|
|
||||||
|
if (!isLeavingCheckout) {
|
||||||
|
void this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => console.error('Error logging out', err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onCheckoutClick(): Promise<void> {
|
||||||
|
if (this.isCreatingPurchase()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cart = this.cartService.cart();
|
||||||
|
const tenant = this.tenantService.tenant();
|
||||||
|
const user = this.authService.user();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
this.isCartOpen.set(false);
|
||||||
|
void this.router.navigate(['/login'], { queryParams: { returnUrl: '/' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cart?.id || !tenant) {
|
||||||
|
this.toastService.danger('No hay un carrito activo para iniciar la compra.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isCreatingPurchase.set(true);
|
||||||
|
try {
|
||||||
|
const purchase = await this.checkoutService.startCheckout(tenant.codigo, {
|
||||||
|
cart_id: cart.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cartService.clearCart();
|
||||||
|
this.isCartOpen.set(false);
|
||||||
|
await this.router.navigate(['/checkout'], {
|
||||||
|
queryParams: { purchase: purchase.id },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create cart purchase:', error);
|
||||||
|
this.toastService.danger('No se pudo iniciar la compra.');
|
||||||
|
} finally {
|
||||||
|
this.isCreatingPurchase.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly footerSections = computed<StoreFooterSection[]>(() => {
|
||||||
|
const helpSubmenues = findMenu(this.tenant()?.menues ?? [], 'help')?.submenues ?? [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
heading: 'Cuenta',
|
||||||
|
links: [
|
||||||
|
...this.accountNavigationMenus().map((menu) => ({
|
||||||
|
label: menu.label,
|
||||||
|
routerLink: menu.route,
|
||||||
|
})),
|
||||||
|
{ label: 'Cerrar sesion', action: 'logout' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Ayuda',
|
||||||
|
links: helpSubmenues.map((menu) => ({
|
||||||
|
label: menu.label,
|
||||||
|
routerLink: menu.route,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
30
src/app/core/services/api-paginated-response.interface.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiResponse } from './api-response.interface';
|
||||||
|
|
||||||
|
export interface ApiPaginationMetaLink {
|
||||||
|
url: string | null;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiPaginationMeta {
|
||||||
|
current_page: number;
|
||||||
|
from: number | null;
|
||||||
|
last_page: number;
|
||||||
|
links: ApiPaginationMetaLink[];
|
||||||
|
path: string;
|
||||||
|
per_page: number;
|
||||||
|
to: number | null;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiPaginationLinks {
|
||||||
|
first: string;
|
||||||
|
last: string;
|
||||||
|
prev: string | null;
|
||||||
|
next: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiPaginatedResponse<T> extends ApiResponse<T> {
|
||||||
|
meta: ApiPaginationMeta;
|
||||||
|
links: ApiPaginationLinks;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface ApiPaginationQueryParams {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
}
|
||||||
5
src/app/core/services/api-response.interface.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface ApiResponse<T> {
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
}
|
||||||
9
src/app/core/services/auth/auth-bootstrap.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
export function authBootstrap(): Promise<void> {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
|
||||||
|
return authService.bootstrap();
|
||||||
|
}
|
||||||
68
src/app/core/services/auth/auth.guards.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { computed, signal } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter, Router, UrlTree } from '@angular/router';
|
||||||
|
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { authGuard, guestOnlyGuard } from './auth.guards';
|
||||||
|
|
||||||
|
function createAuthServiceStub(isAuthenticated = false) {
|
||||||
|
const tokenState = signal<string | null>(isAuthenticated ? 'jwt-token' : null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated: computed(() => tokenState() !== null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('auth guards', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects unauthenticated users to /login', () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub() }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = TestBed.runInInjectionContext(() =>
|
||||||
|
authGuard(null as never, { url: '/checkout?mode=direct' } as never),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result instanceof UrlTree).toBe(true);
|
||||||
|
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe(
|
||||||
|
'/login?returnUrl=%2Fcheckout%3Fmode%3Ddirect',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows authenticated users through authGuard', () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub(true) }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = TestBed.runInInjectionContext(() =>
|
||||||
|
authGuard(null as never, { url: '/checkout' } as never),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects authenticated users away from guest routes', () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub(true) }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = TestBed.runInInjectionContext(() => guestOnlyGuard(null as never, null as never));
|
||||||
|
|
||||||
|
expect(result instanceof UrlTree).toBe(true);
|
||||||
|
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows guests into login/register routes', () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub() }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = TestBed.runInInjectionContext(() => guestOnlyGuard(null as never, null as never));
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
22
src/app/core/services/auth/auth.guards.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
export const authGuard: CanActivateFn = (_route, state) => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
return authService.isAuthenticated()
|
||||||
|
? true
|
||||||
|
: router.createUrlTree(['/login'], {
|
||||||
|
queryParams: { returnUrl: state.url },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const guestOnlyGuard: CanActivateFn = () => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
return authService.isAuthenticated() ? router.createUrlTree(['/']) : true;
|
||||||
|
};
|
||||||
103
src/app/core/services/auth/auth.interceptor.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { computed, signal } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { environment } from '../../../../environments/environment';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { authInterceptor } from './auth.interceptor';
|
||||||
|
|
||||||
|
function createAuthServiceStub(token: string | null = null) {
|
||||||
|
const tokenState = signal(token);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: tokenState.asReadonly(),
|
||||||
|
isAuthenticated: computed(() => tokenState() !== null),
|
||||||
|
clearSession: vi.fn(() => tokenState.set(null))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('authInterceptor', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the bearer token when a session exists', () => {
|
||||||
|
const authService = createAuthServiceStub('jwt-token');
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: AuthService, useValue: authService }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = TestBed.inject(HttpClient);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
client.get(`${environment.url}me`).subscribe();
|
||||||
|
|
||||||
|
const request = httpController.expectOne(`${environment.url}me`);
|
||||||
|
expect(request.request.headers.get('Authorization')).toBe('Bearer jwt-token');
|
||||||
|
request.flush({ id: 1 });
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add the authorization header when no token exists', () => {
|
||||||
|
const authService = createAuthServiceStub();
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: AuthService, useValue: authService }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = TestBed.inject(HttpClient);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
client.get(`${environment.url}productos`).subscribe();
|
||||||
|
|
||||||
|
const request = httpController.expectOne(`${environment.url}productos`);
|
||||||
|
expect(request.request.headers.has('Authorization')).toBe(false);
|
||||||
|
request.flush([]);
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a 401 without clearing the local session', () => {
|
||||||
|
const authService = createAuthServiceStub('expired-token');
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: AuthService, useValue: authService }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = TestBed.inject(HttpClient);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
let receivedError: unknown;
|
||||||
|
let receivedStatus: number | null = null;
|
||||||
|
client.get(`${environment.url}me`).subscribe({
|
||||||
|
error: (error: HttpErrorResponse) => {
|
||||||
|
receivedError = error;
|
||||||
|
receivedStatus = error.status;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpController.expectOne(`${environment.url}me`);
|
||||||
|
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
|
||||||
|
expect(receivedError).toBeInstanceOf(HttpErrorResponse);
|
||||||
|
expect(receivedStatus).toBe(401);
|
||||||
|
expect(authService.clearSession).not.toHaveBeenCalled();
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
src/app/core/services/auth/auth.interceptor.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
export const authInterceptor: HttpInterceptorFn = (
|
||||||
|
request: HttpRequest<unknown>,
|
||||||
|
next: HttpHandlerFn
|
||||||
|
): Observable<HttpEvent<unknown>> => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const token = authService.token();
|
||||||
|
|
||||||
|
const authenticatedRequest = token
|
||||||
|
? request.clone({
|
||||||
|
setHeaders: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: request;
|
||||||
|
|
||||||
|
return next(authenticatedRequest);
|
||||||
|
};
|
||||||
61
src/app/core/services/auth/auth.interfaces.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export interface AuthUser {
|
||||||
|
id: number;
|
||||||
|
nombre_apellido: string;
|
||||||
|
email: string;
|
||||||
|
dni?: string;
|
||||||
|
telefono?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginPayload {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterPayload {
|
||||||
|
nombre_apellido: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
password_confirmation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfilePayload {
|
||||||
|
nombre_apellido: string;
|
||||||
|
email: string;
|
||||||
|
dni?: string;
|
||||||
|
telefono?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
message: string;
|
||||||
|
token: string;
|
||||||
|
token_type: 'Bearer';
|
||||||
|
user: AuthUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterResponse {
|
||||||
|
message: string;
|
||||||
|
data: AuthUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResetPasswordAttemptResponse {
|
||||||
|
message: string;
|
||||||
|
status: 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidateResetPasswordAttemptResponse {
|
||||||
|
message: string;
|
||||||
|
status: 'validated';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResetPasswordPayload {
|
||||||
|
email: string;
|
||||||
|
codigo: string;
|
||||||
|
password: string;
|
||||||
|
password_confirmation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResetPasswordResponse {
|
||||||
|
message: string;
|
||||||
|
status: 'used';
|
||||||
|
}
|
||||||
391
src/app/core/services/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import { PLATFORM_ID, TransferState } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
|
||||||
|
import { environment } from '../../../../environments/environment';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { CookieService } from '../cookie/cookie.service';
|
||||||
|
import { TenantService } from '../tenant.service';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let cookieStore: Record<string, string> = {};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cookieStore = {};
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createCookieServiceStub() {
|
||||||
|
return {
|
||||||
|
get: (name: string) => cookieStore[name] || null,
|
||||||
|
set: (name: string, value: string) => {
|
||||||
|
cookieStore[name] = value;
|
||||||
|
},
|
||||||
|
delete: (name: string) => {
|
||||||
|
delete cookieStore[name];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('stores token and user on successful login', () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
AuthService,
|
||||||
|
{ provide: CookieService, useValue: createCookieServiceStub() },
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
|
||||||
|
},
|
||||||
|
TransferState
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
service.login({ email: 'ada@example.com', password: 'secret123' }).subscribe((user) => {
|
||||||
|
expect(user.email).toBe('ada@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpController.expectOne(`${environment.url}login`);
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
expect(request.request.body).toEqual({
|
||||||
|
email: 'ada@example.com',
|
||||||
|
password: 'secret123',
|
||||||
|
tenant_codigo: 'tenant-test'
|
||||||
|
});
|
||||||
|
expect(request.request.withCredentials).toBe(true);
|
||||||
|
request.flush({
|
||||||
|
message: 'Sesion iniciada correctamente.',
|
||||||
|
token: 'plain-text-token',
|
||||||
|
token_type: 'Bearer',
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
nombre_apellido: 'Ada Lovelace',
|
||||||
|
email: 'ada@example.com'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.token()).toBe('plain-text-token');
|
||||||
|
expect(service.user()?.email).toBe('ada@example.com');
|
||||||
|
expect(service.isAuthenticated()).toBe(true);
|
||||||
|
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates token from cookies and loads the current user during bootstrap', async () => {
|
||||||
|
cookieStore['shopit.auth.token'] = 'persisted-token';
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
AuthService,
|
||||||
|
{ provide: CookieService, useValue: createCookieServiceStub() },
|
||||||
|
TransferState
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
const bootstrapPromise = service.bootstrap();
|
||||||
|
const request = httpController.expectOne(`${environment.url}me`);
|
||||||
|
|
||||||
|
expect(request.request.method).toBe('GET');
|
||||||
|
request.flush({
|
||||||
|
id: 9,
|
||||||
|
nombre_apellido: 'Grace Hopper',
|
||||||
|
email: 'grace@example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(bootstrapPromise).resolves.toBeUndefined();
|
||||||
|
expect(service.user()?.email).toBe('grace@example.com');
|
||||||
|
expect(service.isAuthenticated()).toBe(true);
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends tenant and credentials when completing Google login', () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
AuthService,
|
||||||
|
{ provide: CookieService, useValue: createCookieServiceStub() },
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
|
||||||
|
},
|
||||||
|
TransferState
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
service.completeGoogleLogin('oauth-code').subscribe();
|
||||||
|
|
||||||
|
const request = httpController.expectOne(`${environment.url}auth/google/exchange`);
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
expect(request.request.body).toEqual({
|
||||||
|
oauth_code: 'oauth-code',
|
||||||
|
tenant_codigo: 'tenant-test'
|
||||||
|
});
|
||||||
|
expect(request.request.withCredentials).toBe(true);
|
||||||
|
request.flush({
|
||||||
|
message: 'Sesion iniciada correctamente.',
|
||||||
|
token: 'google-token',
|
||||||
|
token_type: 'Bearer',
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
nombre_apellido: 'Ada Lovelace',
|
||||||
|
email: 'ada@example.com'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears an expired session when /me returns 401', async () => {
|
||||||
|
cookieStore['shopit.auth.token'] = 'expired-token';
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
AuthService,
|
||||||
|
{ provide: CookieService, useValue: createCookieServiceStub() },
|
||||||
|
TransferState
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
const bootstrapPromise = service.bootstrap();
|
||||||
|
const request = httpController.expectOne(`${environment.url}me`);
|
||||||
|
|
||||||
|
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
|
||||||
|
await expect(bootstrapPromise).resolves.toBeUndefined();
|
||||||
|
expect(service.user()).toBeNull();
|
||||||
|
expect(service.token()).toBeNull();
|
||||||
|
expect(service.isAuthenticated()).toBe(false);
|
||||||
|
expect(cookieStore['shopit.auth.token']).toBeUndefined();
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers without creating a session', () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
AuthService,
|
||||||
|
{ provide: CookieService, useValue: createCookieServiceStub() },
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
|
||||||
|
},
|
||||||
|
TransferState
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
service
|
||||||
|
.register({
|
||||||
|
nombre_apellido: 'Ada Lovelace',
|
||||||
|
email: 'ada@example.com',
|
||||||
|
password: 'secret123',
|
||||||
|
password_confirmation: 'secret123'
|
||||||
|
})
|
||||||
|
.subscribe((response) => {
|
||||||
|
expect(response.data.email).toBe('ada@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpController.expectOne(`${environment.url}register`);
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
expect(request.request.body.tenant_codigo).toBe('tenant-test');
|
||||||
|
request.flush({
|
||||||
|
message: 'Usuario registrado correctamente.',
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
nombre_apellido: 'Ada Lovelace',
|
||||||
|
email: 'ada@example.com'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.token()).toBeNull();
|
||||||
|
expect(service.user()).toBeNull();
|
||||||
|
expect(service.isAuthenticated()).toBe(false);
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests a password reset and exposes the final HTTP status', () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
AuthService,
|
||||||
|
{ provide: CookieService, useValue: createCookieServiceStub() },
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
|
||||||
|
},
|
||||||
|
TransferState
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
service.requestPasswordReset('ada@example.com').subscribe((response) => {
|
||||||
|
expect(response.status).toBe(202);
|
||||||
|
expect(response.body?.status).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpController.expectOne(`${environment.url}password/reset-attempts`);
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
expect(request.request.body).toEqual({
|
||||||
|
email: 'ada@example.com',
|
||||||
|
tenant_codigo: 'tenant-test'
|
||||||
|
});
|
||||||
|
request.flush(
|
||||||
|
{
|
||||||
|
message: 'Si el email está registrado, recibirás un código.',
|
||||||
|
status: 'pending'
|
||||||
|
},
|
||||||
|
{ status: 202, statusText: 'Accepted' }
|
||||||
|
);
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates a password reset code and exposes its final status', () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
AuthService,
|
||||||
|
{ provide: CookieService, useValue: createCookieServiceStub() },
|
||||||
|
TransferState
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
service.validatePasswordResetCode('ada@example.com', '0123').subscribe((response) => {
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body?.status).toBe('validated');
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpController.expectOne(`${environment.url}password/reset-attempts/validate`);
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
expect(request.request.body).toEqual({
|
||||||
|
email: 'ada@example.com',
|
||||||
|
codigo: '0123'
|
||||||
|
});
|
||||||
|
request.flush(
|
||||||
|
{
|
||||||
|
message: 'Código validado correctamente.',
|
||||||
|
status: 'validated'
|
||||||
|
},
|
||||||
|
{ status: 200, statusText: 'OK' }
|
||||||
|
);
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the password and exposes the consumed attempt status', () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
AuthService,
|
||||||
|
{ provide: CookieService, useValue: createCookieServiceStub() },
|
||||||
|
TransferState
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
const payload = {
|
||||||
|
email: 'ada@example.com',
|
||||||
|
codigo: '0123',
|
||||||
|
password: 'NewSecret!456',
|
||||||
|
password_confirmation: 'NewSecret!456'
|
||||||
|
};
|
||||||
|
|
||||||
|
service.resetPassword(payload).subscribe((response) => {
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body?.status).toBe('used');
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpController.expectOne(`${environment.url}password/reset`);
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
expect(request.request.body).toEqual(payload);
|
||||||
|
request.flush(
|
||||||
|
{
|
||||||
|
message: 'Contraseña modificada correctamente.',
|
||||||
|
status: 'used'
|
||||||
|
},
|
||||||
|
{ status: 200, statusText: 'OK' }
|
||||||
|
);
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates logout errors without clearing the local session', () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
AuthService,
|
||||||
|
{ provide: CookieService, useValue: createCookieServiceStub() },
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: { getTenant: () => ({ codigo: 'tenant-test' }) }
|
||||||
|
},
|
||||||
|
TransferState
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
service.login({ email: 'ada@example.com', password: 'secret123' }).subscribe();
|
||||||
|
httpController.expectOne(`${environment.url}login`).flush({
|
||||||
|
message: 'Sesion iniciada correctamente.',
|
||||||
|
token: 'plain-text-token',
|
||||||
|
token_type: 'Bearer',
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
nombre_apellido: 'Ada Lovelace',
|
||||||
|
email: 'ada@example.com'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
service.logout().subscribe({
|
||||||
|
error: () => undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoutRequest = httpController.expectOne(`${environment.url}logout`);
|
||||||
|
expect(logoutRequest.request.method).toBe('POST');
|
||||||
|
logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
|
||||||
|
expect(service.token()).toBe('plain-text-token');
|
||||||
|
expect(service.user()?.email).toBe('ada@example.com');
|
||||||
|
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
});
|
||||||
220
src/app/core/services/auth/auth.service.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
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 } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../../../environments/environment';
|
||||||
|
import { BaseApiService } from '../base-api.service';
|
||||||
|
import {
|
||||||
|
AuthUser,
|
||||||
|
LoginPayload,
|
||||||
|
LoginResponse,
|
||||||
|
RegisterPayload,
|
||||||
|
RegisterResponse,
|
||||||
|
ResetPasswordPayload,
|
||||||
|
ResetPasswordAttemptResponse,
|
||||||
|
ResetPasswordResponse,
|
||||||
|
UpdateProfilePayload,
|
||||||
|
ValidateResetPasswordAttemptResponse
|
||||||
|
} from './auth.interfaces';
|
||||||
|
import { CookieService } from '../cookie/cookie.service';
|
||||||
|
import { TenantService } from '../tenant.service';
|
||||||
|
|
||||||
|
const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token';
|
||||||
|
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AuthService 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 userState = signal<AuthUser | null>(null);
|
||||||
|
private readonly tokenState = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly user = this.userState.asReadonly();
|
||||||
|
readonly token = this.tokenState.asReadonly();
|
||||||
|
readonly isAuthenticated = computed(() => this.tokenState() !== null);
|
||||||
|
|
||||||
|
login(payload: LoginPayload): Observable<AuthUser> {
|
||||||
|
const tenant = this.tenantService.getTenant();
|
||||||
|
if (!tenant) {
|
||||||
|
throw new Error('No se pudo resolver el tenant activo.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http
|
||||||
|
.post<LoginResponse>(
|
||||||
|
`${environment.url}login`,
|
||||||
|
{ ...payload, tenant_codigo: tenant.codigo },
|
||||||
|
{
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
|
||||||
|
map((response) => response.user)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
register(payload: RegisterPayload): Observable<RegisterResponse> {
|
||||||
|
const tenantCode = this.tenantService.getTenant()?.codigo;
|
||||||
|
|
||||||
|
return this.http.post<RegisterResponse>(
|
||||||
|
`${environment.url}register`,
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
...(tenantCode ? { tenant_codigo: tenantCode } : {})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestPasswordReset(
|
||||||
|
email: string
|
||||||
|
): Observable<HttpResponse<ResetPasswordAttemptResponse>> {
|
||||||
|
const tenantCode = this.tenantService.getTenant()?.codigo;
|
||||||
|
|
||||||
|
return this.http.post<ResetPasswordAttemptResponse>(
|
||||||
|
`${environment.url}password/reset-attempts`,
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
...(tenantCode ? { tenant_codigo: tenantCode } : {})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
observe: 'response'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
validatePasswordResetCode(
|
||||||
|
email: string,
|
||||||
|
codigo: string
|
||||||
|
): Observable<HttpResponse<ValidateResetPasswordAttemptResponse>> {
|
||||||
|
return this.http.post<ValidateResetPasswordAttemptResponse>(
|
||||||
|
`${environment.url}password/reset-attempts/validate`,
|
||||||
|
{ email, codigo },
|
||||||
|
{
|
||||||
|
observe: 'response'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPassword(payload: ResetPasswordPayload): Observable<HttpResponse<ResetPasswordResponse>> {
|
||||||
|
return this.http.post<ResetPasswordResponse>(`${environment.url}password/reset`, payload, {
|
||||||
|
observe: 'response'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loginWithGoogle(): void {
|
||||||
|
const tenant = this.tenantService.getTenant();
|
||||||
|
if (!tenant) {
|
||||||
|
throw new Error('No se pudo resolver el tenant activo.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = new URL(environment.url);
|
||||||
|
const authorizationUrl = new URL('/auth/google/redirect', apiUrl.origin);
|
||||||
|
authorizationUrl.searchParams.set('tenant', tenant.codigo);
|
||||||
|
authorizationUrl.searchParams.set('return_url', this.document.location.origin);
|
||||||
|
|
||||||
|
this.document.location.assign(authorizationUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
completeGoogleLogin(oauthCode: string): Observable<AuthUser> {
|
||||||
|
const tenant = this.tenantService.getTenant();
|
||||||
|
if (!tenant) {
|
||||||
|
throw new Error('No se pudo resolver el tenant activo.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http
|
||||||
|
.post<LoginResponse>(
|
||||||
|
`${environment.url}auth/google/exchange`,
|
||||||
|
{ oauth_code: oauthCode, tenant_codigo: tenant.codigo },
|
||||||
|
{
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
|
||||||
|
map((response) => response.user)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProfile(payload: UpdateProfilePayload): Observable<AuthUser> {
|
||||||
|
return this.http
|
||||||
|
.put<AuthUser>(`${environment.url}me`, payload)
|
||||||
|
.pipe(tap((user) => this.userState.set(user)));
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(): Observable<void> {
|
||||||
|
if (!this.tokenState()) {
|
||||||
|
this.clearSession();
|
||||||
|
return of(void 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http
|
||||||
|
.post<void>(`${environment.url}logout`, {})
|
||||||
|
.pipe(tap(() => this.clearSession()));
|
||||||
|
}
|
||||||
|
|
||||||
|
async bootstrap(): Promise<void> {
|
||||||
|
this.hydrateSession();
|
||||||
|
|
||||||
|
if (!this.tokenState() || this.userState()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferredUser = this.transferState.get(AUTH_USER_SSR_STATE_KEY, null);
|
||||||
|
if (transferredUser) {
|
||||||
|
this.transferState.remove(AUTH_USER_SSR_STATE_KEY);
|
||||||
|
this.userState.set(transferredUser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await firstValueFrom(this.loadCurrentUser());
|
||||||
|
|
||||||
|
if (isPlatformServer(this.platformId)) {
|
||||||
|
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// An expired token is an expected state, including while rendering on the
|
||||||
|
// server. Do not let it reject the application initializer: Angular's SSR
|
||||||
|
// pipeline can otherwise attempt to serialize HttpErrorResponse internals
|
||||||
|
// as HTTP headers.
|
||||||
|
if (error instanceof HttpErrorResponse && error.status === 401) {
|
||||||
|
this.clearSession();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCurrentUser(): Observable<AuthUser> {
|
||||||
|
return this.http
|
||||||
|
.get<AuthUser>(`${environment.url}me`)
|
||||||
|
.pipe(tap((user) => this.userState.set(user)));
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrateSession(): void {
|
||||||
|
const token = this.cookieService.get(AUTH_TOKEN_COOKIE_KEY);
|
||||||
|
this.tokenState.set(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSession(): void {
|
||||||
|
this.userState.set(null);
|
||||||
|
this.tokenState.set(null);
|
||||||
|
this.cookieService.delete(AUTH_TOKEN_COOKIE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyAuthenticatedState(token: string, user: AuthUser): void {
|
||||||
|
this.tokenState.set(token);
|
||||||
|
this.userState.set(user);
|
||||||
|
this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app/core/services/cart/cart.interface.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export interface CartItemProduct {
|
||||||
|
nombre: string;
|
||||||
|
imagen: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartItem {
|
||||||
|
id: number;
|
||||||
|
cantidad: number;
|
||||||
|
precio_unitario: string;
|
||||||
|
catalog_item_id: number;
|
||||||
|
variant_id: number | null;
|
||||||
|
product: CartItemProduct | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cart {
|
||||||
|
id: number | null;
|
||||||
|
tenant_codigo: string;
|
||||||
|
status: string;
|
||||||
|
items: CartItem[];
|
||||||
|
subtotal: string;
|
||||||
|
}
|
||||||
151
src/app/core/services/cart/cart.service.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
describe('CartService', () => {
|
||||||
|
let service: CartService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let tenantServiceMock: any;
|
||||||
|
|
||||||
|
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,
|
||||||
|
product: {
|
||||||
|
nombre: 'Test Product (Size: M)',
|
||||||
|
imagen: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with null cart signal', () => {
|
||||||
|
expect(service.cart()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load cart and update signal', () => {
|
||||||
|
service.loadCart().subscribe((cart) => {
|
||||||
|
expect(cart).toEqual(mockCart);
|
||||||
|
expect(service.cart()).toEqual(mockCart);
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne('http://api.test/tenants/acme/cart');
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
expect(req.request.withCredentials).toBe(true);
|
||||||
|
req.flush({ data: mockCart });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a custom loading mode to the request context', () => {
|
||||||
|
service.withCustomLoading().loadCart().subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne('http://api.test/tenants/acme/cart');
|
||||||
|
expect(req.request.context.get(LOADING_MODE)).toBe('custom');
|
||||||
|
req.flush({ data: mockCart });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear cart state locally', () => {
|
||||||
|
service.clearCart();
|
||||||
|
|
||||||
|
expect(service.cart()).toEqual({
|
||||||
|
id: null,
|
||||||
|
tenant_codigo: 'acme',
|
||||||
|
status: 'active',
|
||||||
|
items: [],
|
||||||
|
subtotal: '0.00',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add item and update signal', () => {
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update item quantity and update signal', () => {
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove item and update signal', () => {
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
120
src/app/core/services/cart/cart.service.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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 { TenantService } from '../tenant.service';
|
||||||
|
import { Cart } from './cart.interface';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class CartService extends BaseApiService {
|
||||||
|
private readonly tenantService = inject(TenantService);
|
||||||
|
|
||||||
|
private readonly cartState = signal<Cart | null>(null);
|
||||||
|
readonly cart = this.cartState.asReadonly();
|
||||||
|
|
||||||
|
private readonly isUpdatingState = signal<boolean>(false);
|
||||||
|
readonly isUpdating = this.isUpdatingState.asReadonly();
|
||||||
|
|
||||||
|
private get tenantApiUrl(): string {
|
||||||
|
return this.tenantService.getTenantApiUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCart(): void {
|
||||||
|
this.cartState.set({
|
||||||
|
id: null,
|
||||||
|
tenant_codigo: this.tenantService.tenant()?.codigo ?? '',
|
||||||
|
status: 'active',
|
||||||
|
items: [],
|
||||||
|
subtotal: '0.00',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCart(): Observable<Cart> {
|
||||||
|
return this.http
|
||||||
|
.get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, {
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
map((response) => response.data),
|
||||||
|
tap((cart) => this.cartState.set(cart)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addItem(
|
||||||
|
catalogItemId: number,
|
||||||
|
variantId: number | null,
|
||||||
|
cantidad: number,
|
||||||
|
): Observable<ApiResponse<Cart>> {
|
||||||
|
this.isUpdatingState.set(true);
|
||||||
|
return this.http
|
||||||
|
.post<
|
||||||
|
ApiResponse<Cart>
|
||||||
|
>(
|
||||||
|
`${this.tenantApiUrl}/cart/items`,
|
||||||
|
{ catalog_item_id: catalogItemId, variant_id: variantId, cantidad },
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
tap((response) => {
|
||||||
|
this.cartState.set(response.data);
|
||||||
|
this.isUpdatingState.set(false);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
this.isUpdatingState.set(false);
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItemQuantity(
|
||||||
|
cartItemId: number,
|
||||||
|
cantidad: number,
|
||||||
|
): Observable<ApiResponse<Cart>> {
|
||||||
|
this.isUpdatingState.set(true);
|
||||||
|
return this.http
|
||||||
|
.patch<
|
||||||
|
ApiResponse<Cart>
|
||||||
|
>(
|
||||||
|
`${this.tenantApiUrl}/cart/items/${cartItemId}`,
|
||||||
|
{ cantidad },
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
tap((response) => {
|
||||||
|
this.cartState.set(response.data);
|
||||||
|
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.isUpdatingState.set(false);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
this.isUpdatingState.set(false);
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/app/core/services/catalog/catalog.interface.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
sort_order: number;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductAttribute {
|
||||||
|
id: number;
|
||||||
|
codigo: string;
|
||||||
|
nombre: string;
|
||||||
|
is_required: boolean;
|
||||||
|
metadata_schema: Record<string, unknown> | null;
|
||||||
|
type: string;
|
||||||
|
options: ProductAttributeOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InventoryPolicy = 'tracked' | 'unlimited';
|
||||||
|
|
||||||
|
export interface CatalogItemVariant {
|
||||||
|
id: number;
|
||||||
|
event_date_id?: number | null;
|
||||||
|
stock_tecnico: number | null;
|
||||||
|
minimum_use_date?: string | null;
|
||||||
|
maximum_use_date?: string | null;
|
||||||
|
effective_minimum_use_date?: string | null;
|
||||||
|
effective_maximum_use_date?: string | null;
|
||||||
|
values: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogItemEventDate {
|
||||||
|
id: number;
|
||||||
|
variant_id: number;
|
||||||
|
date: string;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string;
|
||||||
|
label: string;
|
||||||
|
stock_tecnico: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
has_tickets: boolean;
|
||||||
|
minimum_use_date: string | null;
|
||||||
|
maximum_use_date: string | null;
|
||||||
|
attributes: ProductAttribute[];
|
||||||
|
variants: CatalogItemVariant[];
|
||||||
|
event_dates?: CatalogItemEventDate[];
|
||||||
|
selected_variant?: SelectedCatalogItemVariant;
|
||||||
|
stock_tecnico?: number | null;
|
||||||
|
images?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CatalogProductLayout = 'row' | 'column_with_image' | 'column_with_cart';
|
||||||
|
export type CatalogGroupLayout = 'paginated' | 'simple' | 'simple_vertical' | 'carousel';
|
||||||
|
|
||||||
|
export interface CatalogFeaturedItemVariant {
|
||||||
|
id: number;
|
||||||
|
stock_tecnico: number | null;
|
||||||
|
values: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogFeaturedItem {
|
||||||
|
id: number;
|
||||||
|
type: CatalogItemType;
|
||||||
|
nombre: string;
|
||||||
|
descripcion?: string | null;
|
||||||
|
precio: number | string;
|
||||||
|
image?: string | null;
|
||||||
|
stock_tecnico?: number | null;
|
||||||
|
variants?: CatalogFeaturedItemVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CatalogFeaturedItems =
|
||||||
|
| ApiPaginatedResponse<CatalogFeaturedItem[]>
|
||||||
|
| CatalogFeaturedItem[];
|
||||||
|
|
||||||
|
export interface CatalogFeaturedGroup {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
layout: CatalogProductLayout;
|
||||||
|
group_layout: CatalogGroupLayout;
|
||||||
|
group_order: number;
|
||||||
|
items: CatalogFeaturedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogCategory {
|
||||||
|
id: number;
|
||||||
|
nombre: string;
|
||||||
|
categoria_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryItemsResponse extends ApiPaginatedResponse<CatalogFeaturedItem[]> {
|
||||||
|
category: CatalogCategory;
|
||||||
|
layout: CatalogProductLayout;
|
||||||
|
group_layout: CatalogGroupLayout;
|
||||||
|
}
|
||||||
111
src/app/core/services/catalog/catalog.service.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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,
|
||||||
|
CategoryItemsResponse,
|
||||||
|
Product,
|
||||||
|
} from './catalog.interface';
|
||||||
|
|
||||||
|
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
|
||||||
|
|
||||||
|
export interface CatalogSearchQueryParams extends ApiPaginationQueryParams {
|
||||||
|
q: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class CatalogService extends BaseApiService {
|
||||||
|
private readonly tenantService = inject(TenantService);
|
||||||
|
|
||||||
|
private get tenantApiUrl(): string {
|
||||||
|
return this.tenantService.getTenantApiUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
getProductos(params?: ApiPaginationQueryParams): Observable<ApiPaginatedResponse<Product[]>> {
|
||||||
|
return this.http.get<ApiPaginatedResponse<Product[]>>(`${this.tenantApiUrl}/productos`, {
|
||||||
|
params: this.buildHttpParams(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCatalog(): Observable<CatalogFeaturedGroup[]> {
|
||||||
|
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchCatalog(
|
||||||
|
params: CatalogSearchQueryParams,
|
||||||
|
): Observable<ApiPaginatedResponse<CatalogFeaturedItem[]>> {
|
||||||
|
return this.http.get<ApiPaginatedResponse<CatalogFeaturedItem[]>>(
|
||||||
|
`${this.tenantApiUrl}/catalog-items`,
|
||||||
|
{
|
||||||
|
params: this.buildHttpParams(params),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategoryItems(
|
||||||
|
categoryId: number,
|
||||||
|
params?: ApiPaginationQueryParams,
|
||||||
|
): Observable<CategoryItemsResponse> {
|
||||||
|
return this.http.get<CategoryItemsResponse>(`${this.tenantApiUrl}/categories/${categoryId}`, {
|
||||||
|
params: this.buildHttpParams(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getFeaturedGroupItems(
|
||||||
|
featuredGroupId: number,
|
||||||
|
params?: ApiPaginationQueryParams,
|
||||||
|
): Observable<CatalogFeaturedItems> {
|
||||||
|
return this.http.get<CatalogFeaturedItems>(
|
||||||
|
`${this.tenantApiUrl}/catalog/featured-groups/${featuredGroupId}/items`,
|
||||||
|
{
|
||||||
|
params: this.buildHttpParams(params),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCatalogItem(
|
||||||
|
id: number,
|
||||||
|
variantId?: number,
|
||||||
|
): Observable<CatalogItemDetail> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (variantId) {
|
||||||
|
params = params.set('variant_id', variantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http
|
||||||
|
.get<ApiResponse<CatalogItemDetail>>(`${this.tenantApiUrl}/catalog-items/${id}`, {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
.pipe(map((response) => response.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
293
src/app/core/services/checkout.service.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { BaseApiService } from './base-api.service';
|
||||||
|
|
||||||
|
export interface UpdatePurchaseCustomerPayload {
|
||||||
|
dni: string;
|
||||||
|
telefono: string;
|
||||||
|
nombre_apellido: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StartCheckoutPayload =
|
||||||
|
| {
|
||||||
|
cart_id: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
direct_item: {
|
||||||
|
catalog_item_id: number;
|
||||||
|
variant_id: number | null;
|
||||||
|
cantidad: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PurchaseStatusResponse {
|
||||||
|
status: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
|
||||||
|
id: number;
|
||||||
|
event_id: number | null;
|
||||||
|
created_at: string | null;
|
||||||
|
total: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseDetailAttributeResponse {
|
||||||
|
name: string;
|
||||||
|
value: string | number | boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseDetailItemResponse {
|
||||||
|
id: number;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: string;
|
||||||
|
line_total: string;
|
||||||
|
source_catalog_item_id: number | null;
|
||||||
|
source_variant_id: number | null;
|
||||||
|
item_details: {
|
||||||
|
nombre: string;
|
||||||
|
descripcion: string | null;
|
||||||
|
slug: string;
|
||||||
|
imagen: string | null;
|
||||||
|
attributes: PurchaseDetailAttributeResponse[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseDetailResponse extends PurchaseStatusResponse {
|
||||||
|
id: number;
|
||||||
|
cart_id: number | null;
|
||||||
|
tenant_codigo: string;
|
||||||
|
event_id: number | null;
|
||||||
|
user_id: number;
|
||||||
|
created_at: string | null;
|
||||||
|
payment_method: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
dni: string | null;
|
||||||
|
transfer_payer_dni: string | null;
|
||||||
|
telefono: string | null;
|
||||||
|
nombre_apellido: string | null;
|
||||||
|
email: string | null;
|
||||||
|
items_source: 'purchase';
|
||||||
|
items: PurchaseDetailItemResponse[];
|
||||||
|
tickets_count?: number;
|
||||||
|
has_generated_tickets?: boolean;
|
||||||
|
subtotal: string;
|
||||||
|
total: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class CheckoutService extends BaseApiService {
|
||||||
|
async startCheckout(
|
||||||
|
tenantCode: string,
|
||||||
|
payload: StartCheckoutPayload,
|
||||||
|
): Promise<PurchaseDetailResponse> {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
|
||||||
|
`${environment.url}tenants/${tenantCode}/compras/start-checkout`,
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
|
||||||
|
|
||||||
|
if (!purchase?.id) {
|
||||||
|
throw new Error('Error al crear la compra.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return purchase;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generatePaymentIntent(
|
||||||
|
tenantCode: string,
|
||||||
|
purchaseId: number,
|
||||||
|
method: 'qr' | 'transfer' | 'telepagos',
|
||||||
|
payerDni?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
const payload: any = { method };
|
||||||
|
if (payerDni) {
|
||||||
|
payload.transfer_payer_dni = payerDni;
|
||||||
|
}
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.post<any>(
|
||||||
|
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/payment-intent`,
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!response) {
|
||||||
|
throw new Error('Error al generar la intención de pago.');
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCustomerData(
|
||||||
|
tenantCode: string,
|
||||||
|
purchaseId: number,
|
||||||
|
payload: UpdatePurchaseCustomerPayload,
|
||||||
|
): Promise<PurchaseDetailResponse> {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
|
||||||
|
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/customer-data`,
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
|
||||||
|
if (!purchase) {
|
||||||
|
throw new Error('Error al actualizar los datos de la compra.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return purchase;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateItemQuantity(
|
||||||
|
tenantCode: string,
|
||||||
|
purchaseId: number,
|
||||||
|
itemId: number,
|
||||||
|
quantity: number,
|
||||||
|
): Promise<PurchaseDetailResponse> {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
|
||||||
|
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/items/${itemId}`,
|
||||||
|
{ quantity },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
|
||||||
|
if (!purchase) {
|
||||||
|
throw new Error('Error al actualizar la cantidad del producto.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return purchase;
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepareItemEditing(
|
||||||
|
tenantCode: string,
|
||||||
|
purchaseId: number,
|
||||||
|
): Promise<PurchaseDetailResponse> {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
|
||||||
|
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/edit-items`,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
|
||||||
|
if (!purchase) {
|
||||||
|
throw new Error('Error al preparar la compra para editarla.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return purchase;
|
||||||
|
}
|
||||||
|
|
||||||
|
async completePurchase(
|
||||||
|
tenantCode: string,
|
||||||
|
purchaseId: number,
|
||||||
|
): Promise<PurchaseStatusResponse> {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
|
||||||
|
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
|
||||||
|
|
||||||
|
if (!purchase) {
|
||||||
|
throw new Error('Error al finalizar la compra.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: purchase.status ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitPurchaseForReview(
|
||||||
|
tenantCode: string,
|
||||||
|
purchaseId: number,
|
||||||
|
): Promise<PurchaseStatusResponse> {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
|
||||||
|
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/review`,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
|
||||||
|
if (!purchase) {
|
||||||
|
throw new Error('Error al enviar la compra a revisi\u00f3n.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: purchase.status ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelPurchase(
|
||||||
|
tenantCode: string,
|
||||||
|
purchaseId: number,
|
||||||
|
): Promise<PurchaseStatusResponse> {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
|
||||||
|
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/cancel`,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
|
||||||
|
if (!purchase) {
|
||||||
|
throw new Error('Error al cancelar la compra.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: purchase.status ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPurchases(
|
||||||
|
tenantCode: string,
|
||||||
|
status?: string,
|
||||||
|
): Promise<{ data: PurchaseSummaryResponse[] }> {
|
||||||
|
let url = `${environment.url}tenants/${tenantCode}/compras`;
|
||||||
|
if (status) {
|
||||||
|
url += `?status=${status}`;
|
||||||
|
}
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.get<{ data: PurchaseSummaryResponse[] }>(url),
|
||||||
|
);
|
||||||
|
if (!response) {
|
||||||
|
throw new Error('Error al obtener las compras.');
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPurchase(
|
||||||
|
tenantCode: string,
|
||||||
|
purchaseId: string | number,
|
||||||
|
): Promise<PurchaseDetailResponse> {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.get<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
|
||||||
|
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
|
||||||
|
|
||||||
|
if (!purchase) {
|
||||||
|
throw new Error('Error al obtener la compra.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return purchase;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractResponseData<T>(response: T | { data?: T } | null | undefined): T | null {
|
||||||
|
if (!response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response === 'object' && 'data' in response && response.data) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/app/core/services/cookie/cookie.service.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import { inject, Injectable, PLATFORM_ID, REQUEST } from '@angular/core';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class CookieService {
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private readonly request = inject(REQUEST, { optional: true });
|
||||||
|
|
||||||
|
get(name: string): string | null {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
return this.getCookieFromString(name, this.document.cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.request) {
|
||||||
|
const cookieHeader = this.request.headers.get('cookie') || '';
|
||||||
|
return this.getCookieFromString(name, cookieHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(name: string, value: string): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
this.document.cookie = `${name}=${value};path=/;SameSite=Lax`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(name: string): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
this.document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Lax`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCookieFromString(name: string, cookieString: string): string | null {
|
||||||
|
if (!cookieString) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameEQ = `${name}=`;
|
||||||
|
const ca = cookieString.split(';');
|
||||||
|
for (let i = 0; i < ca.length; i++) {
|
||||||
|
let c = ca[i];
|
||||||
|
while (c.charAt(0) === ' ') {
|
||||||
|
c = c.substring(1, c.length);
|
||||||
|
}
|
||||||
|
if (c.indexOf(nameEQ) === 0) {
|
||||||
|
return c.substring(nameEQ.length, c.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import { HttpClient, HttpContext, provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
HttpTestingController,
|
||||||
|
provideHttpClientTesting
|
||||||
|
} from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { globalLoadingInterceptor } from './global-loading.interceptor';
|
||||||
|
import { GlobalLoadingService } from './global-loading.service';
|
||||||
|
import { LOADING_MODE, LoadingMode } from './loading-mode';
|
||||||
|
|
||||||
|
describe('globalLoadingInterceptor', () => {
|
||||||
|
let client: HttpClient;
|
||||||
|
let httpTesting: HttpTestingController;
|
||||||
|
let loadingService: GlobalLoadingService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([globalLoadingInterceptor])),
|
||||||
|
provideHttpClientTesting()
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
client = TestBed.inject(HttpClient);
|
||||||
|
httpTesting = TestBed.inject(HttpTestingController);
|
||||||
|
loadingService = TestBed.inject(GlobalLoadingService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpTesting.verify();
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks concurrent requests until the last one completes', () => {
|
||||||
|
client.get('/api/first').subscribe();
|
||||||
|
client.get('/api/second').subscribe();
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
expect(loadingService.isLoading()).toBe(true);
|
||||||
|
|
||||||
|
httpTesting.expectOne('/api/first').flush({});
|
||||||
|
expect(loadingService.isLoading()).toBe(true);
|
||||||
|
|
||||||
|
httpTesting.expectOne('/api/second').flush({});
|
||||||
|
expect(loadingService.isLoading()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops loading when a request fails', () => {
|
||||||
|
client.get('/api/failing').subscribe({ error: () => undefined });
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
expect(loadingService.isLoading()).toBe(true);
|
||||||
|
|
||||||
|
httpTesting.expectOne('/api/failing').flush(
|
||||||
|
{ message: 'Error' },
|
||||||
|
{ status: 500, statusText: 'Server Error' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loadingService.isLoading()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<LoadingMode>(['custom', 'none'])(
|
||||||
|
'does not show the global loader for %s requests',
|
||||||
|
(loadingMode) => {
|
||||||
|
const context = new HttpContext().set(LOADING_MODE, loadingMode);
|
||||||
|
|
||||||
|
client.get(`/api/${loadingMode}`, { context }).subscribe();
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
expect(loadingService.isLoading()).toBe(false);
|
||||||
|
|
||||||
|
httpTesting.expectOne(`/api/${loadingMode}`).flush({});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { Observable, finalize } from 'rxjs';
|
||||||
|
|
||||||
|
import { GlobalLoadingService } from './global-loading.service';
|
||||||
|
import { LOADING_MODE } from './loading-mode';
|
||||||
|
|
||||||
|
export const globalLoadingInterceptor: HttpInterceptorFn = (
|
||||||
|
request: HttpRequest<unknown>,
|
||||||
|
next: HttpHandlerFn
|
||||||
|
): Observable<HttpEvent<unknown>> => {
|
||||||
|
if (request.context.get(LOADING_MODE) !== 'global') {
|
||||||
|
return next(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingService = inject(GlobalLoadingService);
|
||||||
|
loadingService.start();
|
||||||
|
|
||||||
|
return next(request).pipe(finalize(() => loadingService.stop()));
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { GlobalLoadingService } from './global-loading.service';
|
||||||
|
|
||||||
|
describe('GlobalLoadingService', () => {
|
||||||
|
let service: GlobalLoadingService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(GlobalLoadingService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delays the loading state to avoid flashes on fast operations', () => {
|
||||||
|
service.start();
|
||||||
|
|
||||||
|
expect(service.isLoading()).toBe(false);
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
expect(service.isLoading()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the loading state for an operation that exceeds the delay', () => {
|
||||||
|
service.start();
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
expect(service.isLoading()).toBe(true);
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
|
||||||
|
expect(service.isLoading()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays visible until all concurrent operations finish', () => {
|
||||||
|
service.start();
|
||||||
|
service.start();
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
expect(service.isLoading()).toBe(true);
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
expect(service.isLoading()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores extra stop calls without corrupting the counter', () => {
|
||||||
|
service.stop();
|
||||||
|
service.start();
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
|
||||||
|
expect(service.isLoading()).toBe(true);
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
expect(service.isLoading()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import { Injectable, PLATFORM_ID, inject, signal } from '@angular/core';
|
||||||
|
|
||||||
|
const SHOW_DELAY_MS = 150;
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class GlobalLoadingService {
|
||||||
|
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||||
|
private readonly loadingState = signal(false);
|
||||||
|
private pendingOperations = 0;
|
||||||
|
private showTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
readonly isLoading = this.loadingState.asReadonly();
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (!this.isBrowser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingOperations += 1;
|
||||||
|
|
||||||
|
if (this.pendingOperations > 1 || this.loadingState()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showTimer = setTimeout(() => {
|
||||||
|
this.showTimer = undefined;
|
||||||
|
|
||||||
|
if (this.pendingOperations > 0) {
|
||||||
|
this.loadingState.set(true);
|
||||||
|
}
|
||||||
|
}, SHOW_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (!this.isBrowser || this.pendingOperations === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingOperations -= 1;
|
||||||
|
|
||||||
|
if (this.pendingOperations > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cancelShowTimer();
|
||||||
|
this.loadingState.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelShowTimer(): void {
|
||||||
|
if (this.showTimer === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(this.showTimer);
|
||||||
|
this.showTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/app/core/services/global-loading/loading-mode.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { HttpContextToken } from '@angular/common/http';
|
||||||
|
|
||||||
|
export type LoadingMode = 'global' | 'custom' | 'none';
|
||||||
|
|
||||||
|
export const LOADING_MODE = new HttpContextToken<LoadingMode>(() => 'global');
|
||||||
17
src/app/core/services/menu.utils.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Menu } from './tenant.interface';
|
||||||
|
|
||||||
|
export function findMenu(menues: readonly Menu[], menuCode: string): Menu | undefined {
|
||||||
|
for (const menu of menues) {
|
||||||
|
if (menu.code === menuCode) {
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submenu = findMenu(menu.submenues ?? [], menuCode);
|
||||||
|
|
||||||
|
if (submenu) {
|
||||||
|
return submenu;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
267
src/app/core/services/modal.service.spec.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { TestBed, getTestBed } from '@angular/core/testing';
|
||||||
|
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
|
||||||
|
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
|
||||||
|
import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.component';
|
||||||
|
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
|
||||||
|
import { MODAL_DATA, ModalRef, ModalService } from './modal.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: '',
|
||||||
|
})
|
||||||
|
class FirstTestModalComponent {}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: '',
|
||||||
|
})
|
||||||
|
class SecondTestModalComponent {}
|
||||||
|
|
||||||
|
describe('ModalService', () => {
|
||||||
|
let service: ModalService;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
try {
|
||||||
|
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
|
||||||
|
} catch {
|
||||||
|
// Test environment may already be initialized by another setup entrypoint.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [ModalService],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(ModalService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a modal and stores the normalized config', () => {
|
||||||
|
const ref = service.open(FirstTestModalComponent, {
|
||||||
|
title: 'Confirmar compra',
|
||||||
|
data: { productId: 10 },
|
||||||
|
size: 'lg',
|
||||||
|
closeOnBackdrop: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
|
||||||
|
expect(activeModal).not.toBeNull();
|
||||||
|
expect(activeModal?.component).toBe(FirstTestModalComponent);
|
||||||
|
expect(activeModal?.ref).toBe(ref);
|
||||||
|
expect(activeModal?.config).toEqual({
|
||||||
|
title: 'Confirmar compra',
|
||||||
|
data: { productId: 10 },
|
||||||
|
size: 'lg',
|
||||||
|
closeOnBackdrop: false,
|
||||||
|
closeOnEscape: true,
|
||||||
|
showCloseButton: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits the close result and clears the active modal', () => {
|
||||||
|
const ref = service.open(FirstTestModalComponent);
|
||||||
|
const closedSpy = vi.fn();
|
||||||
|
|
||||||
|
ref.afterClosed$.subscribe(closedSpy);
|
||||||
|
ref.close({ confirmed: true });
|
||||||
|
|
||||||
|
expect(closedSpy).toHaveBeenCalledWith({ confirmed: true });
|
||||||
|
expect(service.activeModal()).toBeNull();
|
||||||
|
expect(ref.dismissReason()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks dismiss reasons when closing programmatically', () => {
|
||||||
|
const ref = service.open(FirstTestModalComponent);
|
||||||
|
const closedSpy = vi.fn();
|
||||||
|
|
||||||
|
ref.afterClosed$.subscribe(closedSpy);
|
||||||
|
ref.dismiss();
|
||||||
|
|
||||||
|
expect(closedSpy).toHaveBeenCalledWith(undefined);
|
||||||
|
expect(ref.dismissReason()).toBe('programmatic');
|
||||||
|
expect(service.activeModal()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses the current modal as replaced when another one opens', () => {
|
||||||
|
const firstRef = service.open(FirstTestModalComponent);
|
||||||
|
const firstClosedSpy = vi.fn();
|
||||||
|
|
||||||
|
firstRef.afterClosed$.subscribe(firstClosedSpy);
|
||||||
|
|
||||||
|
const secondRef = service.open(SecondTestModalComponent, {
|
||||||
|
title: 'Segundo modal',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstClosedSpy).toHaveBeenCalledWith(undefined);
|
||||||
|
expect(firstRef.dismissReason()).toBe('replaced');
|
||||||
|
expect(service.activeModal()?.ref).toBe(secondRef);
|
||||||
|
expect(service.activeModal()?.component).toBe(SecondTestModalComponent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects MODAL_DATA and ModalRef into opened components through the host injector contract', () => {
|
||||||
|
const ref = service.open(DataTestModalComponent, {
|
||||||
|
data: { amount: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
|
||||||
|
expect(activeModal?.config.data).toEqual({ amount: 3 });
|
||||||
|
expect(activeModal?.ref).toBe(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the standard confirm modal with default labels', () => {
|
||||||
|
const result$ = service.openConfirm({
|
||||||
|
title: 'Confirmar compra',
|
||||||
|
content: 'Esto confirmara la compra actual.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
|
||||||
|
expect(activeModal?.component).toBe(ConfirmModalComponent);
|
||||||
|
expect(result$).toBeDefined();
|
||||||
|
expect(activeModal?.config).toEqual({
|
||||||
|
title: 'Confirmar compra',
|
||||||
|
data: {
|
||||||
|
content: 'Esto confirmara la compra actual.',
|
||||||
|
confirmLabel: 'Confirmar',
|
||||||
|
cancelLabel: 'Cancelar',
|
||||||
|
},
|
||||||
|
size: 'md',
|
||||||
|
closeOnBackdrop: true,
|
||||||
|
closeOnEscape: true,
|
||||||
|
showCloseButton: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the confirm modal close result to true', async () => {
|
||||||
|
const result$ = service.openConfirm({
|
||||||
|
title: 'Confirmar compra',
|
||||||
|
content: 'Esto confirmara la compra actual.',
|
||||||
|
});
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
const resultPromise = firstValueFrom(result$);
|
||||||
|
|
||||||
|
activeModal?.ref.close(true);
|
||||||
|
|
||||||
|
await expect(resultPromise).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps dismissing a confirm modal to false', async () => {
|
||||||
|
const result$ = service.openConfirmDelete({
|
||||||
|
title: 'Eliminar producto',
|
||||||
|
content: 'Se eliminara el producto.',
|
||||||
|
});
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
const resultPromise = firstValueFrom(result$);
|
||||||
|
|
||||||
|
activeModal?.ref.dismiss('escape');
|
||||||
|
|
||||||
|
await expect(resultPromise).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the delete confirm modal preserving modal overrides', () => {
|
||||||
|
service.openConfirmDelete({
|
||||||
|
title: 'Eliminar producto',
|
||||||
|
content: 'Se eliminara el producto.',
|
||||||
|
confirmLabel: 'Eliminar',
|
||||||
|
cancelLabel: 'Conservar',
|
||||||
|
size: 'lg',
|
||||||
|
closeOnBackdrop: false,
|
||||||
|
closeOnEscape: false,
|
||||||
|
showCloseButton: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
|
||||||
|
expect(activeModal?.component).toBe(ConfirmDeleteModalComponent);
|
||||||
|
expect(activeModal?.config).toEqual({
|
||||||
|
title: 'Eliminar producto',
|
||||||
|
data: {
|
||||||
|
content: 'Se eliminara el producto.',
|
||||||
|
confirmLabel: 'Eliminar',
|
||||||
|
cancelLabel: 'Conservar',
|
||||||
|
},
|
||||||
|
size: 'lg',
|
||||||
|
closeOnBackdrop: false,
|
||||||
|
closeOnEscape: false,
|
||||||
|
showCloseButton: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the simple modal with default button label', () => {
|
||||||
|
service.openSimple({
|
||||||
|
title: 'Aviso',
|
||||||
|
content: 'Este es un aviso simple.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
|
||||||
|
expect(activeModal?.component).toBe(SimpleModalComponent);
|
||||||
|
expect(activeModal?.config).toEqual({
|
||||||
|
title: 'Aviso',
|
||||||
|
data: {
|
||||||
|
content: 'Este es un aviso simple.',
|
||||||
|
buttonLabel: 'Entendido',
|
||||||
|
},
|
||||||
|
size: 'md',
|
||||||
|
closeOnBackdrop: true,
|
||||||
|
closeOnEscape: true,
|
||||||
|
showCloseButton: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the simple modal close result to undefined', async () => {
|
||||||
|
const result$ = service.openSimple({
|
||||||
|
title: 'Aviso',
|
||||||
|
content: 'Este es un aviso simple.',
|
||||||
|
});
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
const resultPromise = firstValueFrom(result$);
|
||||||
|
|
||||||
|
activeModal?.ref.close();
|
||||||
|
|
||||||
|
await expect(resultPromise).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the QR modal with the ticket information and compact size', () => {
|
||||||
|
service.openQr({
|
||||||
|
title: 'Entrada general',
|
||||||
|
id: 42,
|
||||||
|
date: '09 de octubre',
|
||||||
|
ticket: 'ticket-payload',
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeModal = service.activeModal();
|
||||||
|
|
||||||
|
expect(activeModal?.component).toBe(QrModalComponent);
|
||||||
|
expect(activeModal?.config).toEqual({
|
||||||
|
title: 'Entrada general',
|
||||||
|
data: {
|
||||||
|
id: 42,
|
||||||
|
date: '09 de octubre',
|
||||||
|
ticket: 'ticket-payload',
|
||||||
|
},
|
||||||
|
size: 'qr',
|
||||||
|
closeOnBackdrop: true,
|
||||||
|
closeOnEscape: true,
|
||||||
|
showCloseButton: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: '',
|
||||||
|
})
|
||||||
|
class DataTestModalComponent {
|
||||||
|
readonly data = inject(MODAL_DATA);
|
||||||
|
readonly modalRef = inject(ModalRef);
|
||||||
|
}
|
||||||
252
src/app/core/services/modal.service.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { InjectionToken, Injectable, Type, signal } from '@angular/core';
|
||||||
|
import { Observable, Subject, map } from 'rxjs';
|
||||||
|
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
|
||||||
|
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
|
||||||
|
import { QrModalComponent } from '../../shared/components/qr-modal/qr-modal.component';
|
||||||
|
import { SimpleModalComponent } from '../../shared/components/simple-modal/simple-modal.component';
|
||||||
|
|
||||||
|
export type ModalSize = 'qr' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||||
|
export type ModalDismissReason = 'backdrop' | 'escape' | 'programmatic' | 'replaced';
|
||||||
|
|
||||||
|
export interface ModalConfig<TData = unknown> {
|
||||||
|
title?: string;
|
||||||
|
data?: TData;
|
||||||
|
size?: ModalSize;
|
||||||
|
closeOnBackdrop?: boolean;
|
||||||
|
closeOnEscape?: boolean;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedModalConfig<TData = unknown> extends Omit<
|
||||||
|
ModalConfig<TData>,
|
||||||
|
'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
|
||||||
|
> {
|
||||||
|
size: ModalSize;
|
||||||
|
closeOnBackdrop: boolean;
|
||||||
|
closeOnEscape: boolean;
|
||||||
|
showCloseButton: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmModalData {
|
||||||
|
content: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmModalConfig extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
|
||||||
|
content: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimpleModalData {
|
||||||
|
content: string;
|
||||||
|
buttonLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimpleModalConfig extends Omit<ModalConfig<SimpleModalData>, 'data'> {
|
||||||
|
content: string;
|
||||||
|
buttonLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QrModalData {
|
||||||
|
id: string | number;
|
||||||
|
date: string;
|
||||||
|
ticket: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QrModalConfig extends Omit<ModalConfig<QrModalData>, 'data' | 'title'> {
|
||||||
|
title: string;
|
||||||
|
id: string | number;
|
||||||
|
date: string;
|
||||||
|
ticket: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveModalState<TResult = unknown, TData = unknown> {
|
||||||
|
component: Type<unknown>;
|
||||||
|
config: NormalizedModalConfig<TData>;
|
||||||
|
ref: ModalRef<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODAL_DATA = new InjectionToken<unknown>('MODAL_DATA');
|
||||||
|
|
||||||
|
const DEFAULT_MODAL_CONFIG: Pick<
|
||||||
|
NormalizedModalConfig,
|
||||||
|
'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
|
||||||
|
> = {
|
||||||
|
size: 'md',
|
||||||
|
closeOnBackdrop: true,
|
||||||
|
closeOnEscape: true,
|
||||||
|
showCloseButton: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CONFIRM_MODAL_LABELS = {
|
||||||
|
confirmLabel: 'Confirmar',
|
||||||
|
cancelLabel: 'Cancelar',
|
||||||
|
} satisfies Pick<ConfirmModalData, 'confirmLabel' | 'cancelLabel'>;
|
||||||
|
|
||||||
|
export class ModalRef<TResult = unknown> {
|
||||||
|
private readonly afterClosedSubject = new Subject<TResult | undefined>();
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
readonly afterClosed$: Observable<TResult | undefined> = this.afterClosedSubject.asObservable();
|
||||||
|
readonly dismissReason = signal<ModalDismissReason | null>(null);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly closeHandler: (result?: TResult) => void,
|
||||||
|
private readonly dismissHandler: (reason: ModalDismissReason) => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
close(result?: TResult): void {
|
||||||
|
if (this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closeHandler(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(reason: ModalDismissReason = 'programmatic'): void {
|
||||||
|
if (this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dismissHandler(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize(result?: TResult, dismissReason?: ModalDismissReason): void {
|
||||||
|
if (this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closed = true;
|
||||||
|
this.dismissReason.set(dismissReason ?? null);
|
||||||
|
this.afterClosedSubject.next(result);
|
||||||
|
this.afterClosedSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class ModalService {
|
||||||
|
private readonly activeModalState = signal<ActiveModalState | null>(null);
|
||||||
|
readonly activeModal = this.activeModalState.asReadonly();
|
||||||
|
|
||||||
|
open<TComponent, TResult = unknown, TData = unknown>(
|
||||||
|
component: Type<TComponent>,
|
||||||
|
config: ModalConfig<TData> = {},
|
||||||
|
): ModalRef<TResult> {
|
||||||
|
this.activeModalState()?.ref.dismiss('replaced');
|
||||||
|
|
||||||
|
let ref!: ModalRef<TResult>;
|
||||||
|
ref = new ModalRef<TResult>(
|
||||||
|
(result) => this.close(ref, result),
|
||||||
|
(reason) => this.dismiss(ref, reason),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.activeModalState.set({
|
||||||
|
component,
|
||||||
|
config: this.normalizeConfig(config),
|
||||||
|
ref: ref as ModalRef<unknown>,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
openConfirm(config: ConfirmModalConfig): Observable<boolean> {
|
||||||
|
return this.openConfirmRef(config).afterClosed$.pipe(map((result) => result === true));
|
||||||
|
}
|
||||||
|
|
||||||
|
openConfirmDelete(config: ConfirmModalConfig): Observable<boolean> {
|
||||||
|
return this.openConfirmDeleteRef(config).afterClosed$.pipe(map((result) => result === true));
|
||||||
|
}
|
||||||
|
|
||||||
|
openConfirmRef(config: ConfirmModalConfig): ModalRef<boolean> {
|
||||||
|
return this.open(ConfirmModalComponent, this.buildConfirmModalConfig(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef<boolean> {
|
||||||
|
return this.open(ConfirmDeleteModalComponent, this.buildConfirmModalConfig(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
openSimple(config: SimpleModalConfig): Observable<void> {
|
||||||
|
return this.openSimpleRef(config).afterClosed$.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
openSimpleRef(config: SimpleModalConfig): ModalRef<void> {
|
||||||
|
return this.open(SimpleModalComponent, this.buildSimpleModalConfig(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
openQr(config: QrModalConfig): Observable<void> {
|
||||||
|
return this.openQrRef(config).afterClosed$.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
openQrRef(config: QrModalConfig): ModalRef<void> {
|
||||||
|
const { title, id, date, ticket, size = 'qr', ...modalConfig } = config;
|
||||||
|
|
||||||
|
return this.open(QrModalComponent, {
|
||||||
|
...modalConfig,
|
||||||
|
title,
|
||||||
|
size,
|
||||||
|
data: { id, date, ticket },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private close<TResult>(ref: ModalRef<TResult>, result?: TResult): void {
|
||||||
|
if (this.activeModalState()?.ref !== ref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.finalize(result);
|
||||||
|
this.activeModalState.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private dismiss<TResult>(
|
||||||
|
ref: ModalRef<TResult>,
|
||||||
|
reason: ModalDismissReason = 'programmatic',
|
||||||
|
): void {
|
||||||
|
if (this.activeModalState()?.ref !== ref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.finalize(undefined, reason);
|
||||||
|
this.activeModalState.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeConfig<TData>(config: ModalConfig<TData>): NormalizedModalConfig<TData> {
|
||||||
|
return {
|
||||||
|
...DEFAULT_MODAL_CONFIG,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildConfirmModalConfig(config: ConfirmModalConfig): ModalConfig<ConfirmModalData> {
|
||||||
|
const {
|
||||||
|
content,
|
||||||
|
confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel,
|
||||||
|
cancelLabel = DEFAULT_CONFIRM_MODAL_LABELS.cancelLabel,
|
||||||
|
...modalConfig
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...modalConfig,
|
||||||
|
data: {
|
||||||
|
content,
|
||||||
|
confirmLabel,
|
||||||
|
cancelLabel,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSimpleModalConfig(config: SimpleModalConfig): ModalConfig<SimpleModalData> {
|
||||||
|
const { content, buttonLabel = 'Entendido', ...modalConfig } = config;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...modalConfig,
|
||||||
|
data: {
|
||||||
|
content,
|
||||||
|
buttonLabel,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/core/services/tenant-bootstrap.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { tenantBootstrap } from './tenant-bootstrap';
|
||||||
|
import { TenantService } from './tenant.service';
|
||||||
|
|
||||||
|
describe('tenantBootstrap', () => {
|
||||||
|
it('waits for TenantService.bootstrap to resolve', async () => {
|
||||||
|
const bootstrapSpy = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: {
|
||||||
|
bootstrap: bootstrapSpy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(TestBed.runInInjectionContext(() => tenantBootstrap())).resolves.toBeUndefined();
|
||||||
|
expect(bootstrapSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates tenant bootstrap failures', async () => {
|
||||||
|
const bootstrapSpy = vi.fn().mockRejectedValue(new Error('bootstrap failed'));
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: {
|
||||||
|
bootstrap: bootstrapSpy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(TestBed.runInInjectionContext(() => tenantBootstrap())).rejects.toThrow(
|
||||||
|
'bootstrap failed'
|
||||||
|
);
|
||||||
|
expect(bootstrapSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
9
src/app/core/services/tenant-bootstrap.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
|
||||||
|
import { TenantService } from './tenant.service';
|
||||||
|
|
||||||
|
export function tenantBootstrap(): Promise<void> {
|
||||||
|
const tenantService = inject(TenantService);
|
||||||
|
|
||||||
|
return tenantService.bootstrap();
|
||||||
|
}
|
||||||
25
src/app/core/services/tenant-ssr-cache.store.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { makeStateKey } from '@angular/core';
|
||||||
|
|
||||||
|
import { Tenant } from './tenant.interface';
|
||||||
|
|
||||||
|
export type TenantBootstrapStatus = 'idle' | 'ready' | 'not-found';
|
||||||
|
|
||||||
|
export type TenantSsrState =
|
||||||
|
| {
|
||||||
|
status: 'ready';
|
||||||
|
tenant: Tenant;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'not-found';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_TENANT_BRANDING = {
|
||||||
|
primaryColor: '#102bda',
|
||||||
|
secondaryColor: '#9e9e9e',
|
||||||
|
dangerColor: '#fd4c4c',
|
||||||
|
successColor: '#198754',
|
||||||
|
headerBgColor: '#313131',
|
||||||
|
footerBgColor: '#313131'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const TENANT_SSR_STATE_KEY = makeStateKey<TenantSsrState | null>('tenant-ssr-state');
|
||||||
123
src/app/core/services/tenant.interface.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { ApiResponse } from './api-response.interface';
|
||||||
|
|
||||||
|
export interface BankAccount {
|
||||||
|
id: number;
|
||||||
|
tenant_code: string;
|
||||||
|
titular: string;
|
||||||
|
entidad: string;
|
||||||
|
alias: string;
|
||||||
|
cvu: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebsiteType {
|
||||||
|
codigo: string;
|
||||||
|
nombre: string;
|
||||||
|
primary_color: string | null;
|
||||||
|
secondary_color: string | null;
|
||||||
|
danger_color: string | null;
|
||||||
|
success_color: string | null;
|
||||||
|
site_logo: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeroConfig {
|
||||||
|
background_image_id?: string | null;
|
||||||
|
title_html?: string | null;
|
||||||
|
description_html?: string | null;
|
||||||
|
button_text?: string | null;
|
||||||
|
button_href?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdditionalInfoConfig {
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventDate {
|
||||||
|
id?: number;
|
||||||
|
date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventConfig {
|
||||||
|
id?: number;
|
||||||
|
title?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
dates_text?: string | null;
|
||||||
|
dates?: EventDate[] | null;
|
||||||
|
contact?: SocialMedia[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveEventDate {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
time_start: string;
|
||||||
|
time_end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveEvent {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
dates: ActiveEventDate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebsiteExtras {
|
||||||
|
carousel?: string[];
|
||||||
|
heroConfig?: HeroConfig | null;
|
||||||
|
additionalInfoConfig?: AdditionalInfoConfig | null;
|
||||||
|
[extraName: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Menu {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
parent_menu_code: string | null;
|
||||||
|
content_type: 'static' | 'dynamic';
|
||||||
|
route: string;
|
||||||
|
static_content?: unknown;
|
||||||
|
submenues: Menu[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialMedia {
|
||||||
|
code: string;
|
||||||
|
icon: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
nombre: string;
|
||||||
|
subcategories: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tenant {
|
||||||
|
id: number;
|
||||||
|
codigo: string;
|
||||||
|
nombre: string;
|
||||||
|
dominio: string;
|
||||||
|
primary_color: string;
|
||||||
|
secondary_color: string;
|
||||||
|
danger_color: string;
|
||||||
|
success_color: string;
|
||||||
|
header_bg_color: string;
|
||||||
|
footer_bg_color: string;
|
||||||
|
header_logo: string;
|
||||||
|
footer_logo: string;
|
||||||
|
website_type_code?: string | null;
|
||||||
|
website_type?: WebsiteType | null;
|
||||||
|
extras?: WebsiteExtras;
|
||||||
|
active_event_id?: number | null;
|
||||||
|
active_event?: ActiveEvent | null;
|
||||||
|
selected_bank_account_id?: number | null;
|
||||||
|
selected_bank_account?: BankAccount | null;
|
||||||
|
search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart';
|
||||||
|
search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel';
|
||||||
|
search_items_per_page?: number;
|
||||||
|
social_media?: SocialMedia[];
|
||||||
|
menues?: Menu[];
|
||||||
|
categories: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TenantBootstrapResponse = ApiResponse<Tenant>;
|
||||||
238
src/app/core/services/tenant.service.spec.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { PLATFORM_ID, REQUEST, RESPONSE_INIT, TransferState } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
|
||||||
|
import { TENANT_SSR_STATE_KEY } from './tenant-ssr-cache.store';
|
||||||
|
import { TenantService } from './tenant.service';
|
||||||
|
|
||||||
|
const tenant: Tenant = {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'test',
|
||||||
|
nombre: 'Test Tenant',
|
||||||
|
dominio: 'localhost',
|
||||||
|
primary_color: '#6376F3',
|
||||||
|
secondary_color: '#A0A0A0',
|
||||||
|
danger_color: '#FF8888',
|
||||||
|
success_color: '#198754',
|
||||||
|
header_bg_color: '#313131',
|
||||||
|
footer_bg_color: '#313131',
|
||||||
|
header_logo: 'https://example.com/header.png',
|
||||||
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Remeras',
|
||||||
|
subcategories: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
nombre: 'Manga corta',
|
||||||
|
subcategories: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const tenantResponse: TenantBootstrapResponse = {
|
||||||
|
data: tenant
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TenantService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
try {
|
||||||
|
window.history.replaceState({}, '', 'http://localhost:4200/');
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore SecurityError under test environments
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(TenantService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
const bootstrapPromise = service.bootstrap();
|
||||||
|
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
|
||||||
|
|
||||||
|
expect(request.request.method).toBe('GET');
|
||||||
|
|
||||||
|
request.flush(tenantResponse);
|
||||||
|
|
||||||
|
await expect(bootstrapPromise).resolves.toBeUndefined();
|
||||||
|
expect(service.status()).toBe('ready');
|
||||||
|
expect(service.tenant()).toEqual(tenant);
|
||||||
|
expect(service.getTenant()).toEqual(tenant);
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates the tenant from TransferState without performing HTTP on the browser', async () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(TenantService);
|
||||||
|
const transferState = TestBed.inject(TransferState);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
transferState.set(TENANT_SSR_STATE_KEY, {
|
||||||
|
status: 'ready',
|
||||||
|
tenant
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.bootstrap()).resolves.toBeUndefined();
|
||||||
|
expect(service.status()).toBe('ready');
|
||||||
|
expect(service.tenant()).toEqual(tenant);
|
||||||
|
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toBeNull();
|
||||||
|
|
||||||
|
httpController.expectNone(() => true);
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the forwarded host during SSR and stores the resolved tenant in TransferState', async () => {
|
||||||
|
const responseInit: ResponseInit = {};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
TenantService,
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
|
{
|
||||||
|
provide: REQUEST,
|
||||||
|
useValue: new Request('https://internal.example/render', {
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-host': 'STORE.EXAMPLE.COM:443, proxy.internal',
|
||||||
|
host: 'ignored.example.com'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ provide: RESPONSE_INIT, useValue: responseInit }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(TenantService);
|
||||||
|
const transferState = TestBed.inject(TransferState);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
const bootstrapPromise = service.bootstrap();
|
||||||
|
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/store.example.com`);
|
||||||
|
|
||||||
|
request.flush(tenantResponse);
|
||||||
|
|
||||||
|
await expect(bootstrapPromise).resolves.toBeUndefined();
|
||||||
|
expect(service.status()).toBe('ready');
|
||||||
|
expect(service.tenant()).toEqual(tenant);
|
||||||
|
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
|
||||||
|
status: 'ready',
|
||||||
|
tenant
|
||||||
|
});
|
||||||
|
expect(responseInit.status).toBeUndefined();
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores a not-found state and sets the SSR response status to 404 when the tenant does not exist', async () => {
|
||||||
|
const responseInit: ResponseInit = {};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
TenantService,
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
|
{
|
||||||
|
provide: REQUEST,
|
||||||
|
useValue: new Request('https://internal.example/render', {
|
||||||
|
headers: {
|
||||||
|
host: 'missing.example.com:8443'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ provide: RESPONSE_INIT, useValue: responseInit }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(TenantService);
|
||||||
|
const transferState = TestBed.inject(TransferState);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
const bootstrapPromise = service.bootstrap();
|
||||||
|
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/missing.example.com`);
|
||||||
|
|
||||||
|
request.flush({ message: 'Not Found' }, { status: 404, statusText: 'Not Found' });
|
||||||
|
|
||||||
|
await expect(bootstrapPromise).resolves.toBeUndefined();
|
||||||
|
expect(service.status()).toBe('not-found');
|
||||||
|
expect(service.tenant()).toBeNull();
|
||||||
|
expect(service.getTenant()).toBeNull();
|
||||||
|
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
|
||||||
|
status: 'not-found'
|
||||||
|
});
|
||||||
|
expect(responseInit.status).toBe(404);
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when the bootstrap response does not contain tenant data', async () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(TenantService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
const bootstrapPromise = service.bootstrap();
|
||||||
|
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
|
||||||
|
|
||||||
|
request.flush({});
|
||||||
|
|
||||||
|
await expect(bootstrapPromise).rejects.toThrow('Tenant bootstrap returned no data');
|
||||||
|
expect(service.status()).toBe('idle');
|
||||||
|
expect(service.tenant()).toBeNull();
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates non-404 backend failures', async () => {
|
||||||
|
const responseInit: ResponseInit = {};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
TenantService,
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
|
{
|
||||||
|
provide: REQUEST,
|
||||||
|
useValue: new Request('https://internal.example/render', {
|
||||||
|
headers: {
|
||||||
|
host: 'broken.example.com'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ provide: RESPONSE_INIT, useValue: responseInit }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = TestBed.inject(TenantService);
|
||||||
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
const bootstrapPromise = service.bootstrap();
|
||||||
|
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/broken.example.com`);
|
||||||
|
|
||||||
|
request.flush({ message: 'Boom' }, { status: 500, statusText: 'Server Error' });
|
||||||
|
|
||||||
|
await expect(bootstrapPromise).rejects.toThrow('Http failure response');
|
||||||
|
expect(service.status()).toBe('idle');
|
||||||
|
expect(responseInit.status).toBeUndefined();
|
||||||
|
|
||||||
|
httpController.verify();
|
||||||
|
});
|
||||||
|
});
|
||||||
177
src/app/core/services/tenant.service.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
inject,
|
||||||
|
Injectable,
|
||||||
|
PLATFORM_ID,
|
||||||
|
REQUEST,
|
||||||
|
RESPONSE_INIT,
|
||||||
|
signal,
|
||||||
|
TransferState
|
||||||
|
} from '@angular/core';
|
||||||
|
import { IS_DISCOVERING_ROUTES } from '@angular/ssr';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { BaseApiService } from './base-api.service';
|
||||||
|
import { Tenant, TenantBootstrapResponse } from './tenant.interface';
|
||||||
|
import {
|
||||||
|
TENANT_SSR_STATE_KEY,
|
||||||
|
TenantBootstrapStatus,
|
||||||
|
TenantSsrState
|
||||||
|
} from './tenant-ssr-cache.store';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class TenantService extends BaseApiService {
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
private readonly request = inject(REQUEST, { optional: true });
|
||||||
|
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
||||||
|
private readonly transferState = inject(TransferState);
|
||||||
|
private readonly isDiscoveringRoutes = inject(IS_DISCOVERING_ROUTES, { optional: true }) ?? false;
|
||||||
|
private readonly tenantState = signal<Tenant | null>(null);
|
||||||
|
private readonly statusState = signal<TenantBootstrapStatus>('idle');
|
||||||
|
|
||||||
|
readonly tenant = this.tenantState.asReadonly();
|
||||||
|
readonly status = this.statusState.asReadonly();
|
||||||
|
|
||||||
|
getTenant(): Tenant | null {
|
||||||
|
return this.tenantState();
|
||||||
|
}
|
||||||
|
|
||||||
|
getTenantApiUrl(): string {
|
||||||
|
const tenant = this.tenantState();
|
||||||
|
if (!tenant) {
|
||||||
|
throw new Error('No se pudo resolver el tenant activo.');
|
||||||
|
}
|
||||||
|
return `${environment.url}tenants/${tenant.codigo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async bootstrap(): Promise<void> {
|
||||||
|
if (this.statusState() !== 'idle') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDiscoveringRoutes) {
|
||||||
|
this.statusState.set('ready');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferredState = this.transferState.get(TENANT_SSR_STATE_KEY, null);
|
||||||
|
|
||||||
|
if (transferredState) {
|
||||||
|
this.transferState.remove(TENANT_SSR_STATE_KEY);
|
||||||
|
this.applyState(transferredState);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = this.resolveDomain();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.get<TenantBootstrapResponse>(
|
||||||
|
`${environment.url}tenants/bootstrap/${domain}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response?.data) {
|
||||||
|
throw new Error(`Tenant bootstrap returned no data for domain "${domain}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setReady(response.data);
|
||||||
|
this.persistSsrState({
|
||||||
|
status: 'ready',
|
||||||
|
tenant: response.data
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isNotFoundError(error)) {
|
||||||
|
this.setNotFound();
|
||||||
|
this.persistSsrState({ status: 'not-found' });
|
||||||
|
|
||||||
|
if (this.responseInit) {
|
||||||
|
this.responseInit.status = 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveDomain(): string {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
const domain = window.location.hostname;
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.normalizeHost(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestHost =
|
||||||
|
this.request?.headers.get('x-forwarded-host') ??
|
||||||
|
this.request?.headers.get('host') ??
|
||||||
|
(this.request ? new URL(this.request.url).host : null);
|
||||||
|
|
||||||
|
if (!requestHost) {
|
||||||
|
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.normalizeHost(requestHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeHost(host: string): string {
|
||||||
|
const [firstHost = ''] = host.split(',');
|
||||||
|
const normalizedHost = firstHost.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!normalizedHost) {
|
||||||
|
throw new Error('Tenant bootstrap could not resolve the current domain.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedHost.startsWith('[')) {
|
||||||
|
const closingBracketIndex = normalizedHost.indexOf(']');
|
||||||
|
|
||||||
|
if (closingBracketIndex >= 0) {
|
||||||
|
return normalizedHost.slice(1, closingBracketIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedHost.replace(/:\d+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyState(state: TenantSsrState): void {
|
||||||
|
if (state.status === 'ready') {
|
||||||
|
this.setReady(state.tenant);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setReady(tenant: Tenant): void {
|
||||||
|
this.tenantState.set(tenant);
|
||||||
|
this.statusState.set('ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
private setNotFound(): void {
|
||||||
|
this.tenantState.set(null);
|
||||||
|
this.statusState.set('not-found');
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistSsrState(state: TenantSsrState): void {
|
||||||
|
if (isPlatformServer(this.platformId)) {
|
||||||
|
this.transferState.set(TENANT_SSR_STATE_KEY, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isNotFoundError(error: unknown): error is HttpErrorResponse {
|
||||||
|
return error instanceof HttpErrorResponse && error.status === 404;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/app/core/services/toast.service.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
|
describe('ToastService', () => {
|
||||||
|
let service: ToastService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [ToastService]
|
||||||
|
});
|
||||||
|
service = TestBed.inject(ToastService);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with an empty list of toasts', () => {
|
||||||
|
expect(service.toasts()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show a toast and assign an id', () => {
|
||||||
|
const id = service.show('Hello world', 'info');
|
||||||
|
expect(id).toBeTruthy();
|
||||||
|
expect(service.toasts().length).toBe(1);
|
||||||
|
expect(service.toasts()[0]).toEqual({
|
||||||
|
id,
|
||||||
|
message: 'Hello world',
|
||||||
|
type: 'info',
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add specific variant toasts via helper methods', () => {
|
||||||
|
const infoId = service.info('Info message');
|
||||||
|
const dangerId = service.danger('Danger message', 5000);
|
||||||
|
const successId = service.success('Success message', 0);
|
||||||
|
|
||||||
|
const toasts = service.toasts();
|
||||||
|
expect(toasts.length).toBe(3);
|
||||||
|
|
||||||
|
expect(toasts.find((t) => t.id === infoId)).toEqual({
|
||||||
|
id: infoId,
|
||||||
|
message: 'Info message',
|
||||||
|
type: 'info',
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toasts.find((t) => t.id === dangerId)).toEqual({
|
||||||
|
id: dangerId,
|
||||||
|
message: 'Danger message',
|
||||||
|
type: 'danger',
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toasts.find((t) => t.id === successId)).toEqual({
|
||||||
|
id: successId,
|
||||||
|
message: 'Success message',
|
||||||
|
type: 'success',
|
||||||
|
duration: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should dismiss a toast manually by id', () => {
|
||||||
|
const id1 = service.show('Toast 1');
|
||||||
|
const id2 = service.show('Toast 2');
|
||||||
|
|
||||||
|
expect(service.toasts().length).toBe(2);
|
||||||
|
|
||||||
|
service.dismiss(id1);
|
||||||
|
|
||||||
|
expect(service.toasts().length).toBe(1);
|
||||||
|
expect(service.toasts()[0].id).toBe(id2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should automatically dismiss a toast after the duration', () => {
|
||||||
|
service.show('Auto dismiss toast', 'info', 2000);
|
||||||
|
|
||||||
|
expect(service.toasts().length).toBe(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
|
||||||
|
expect(service.toasts().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dismiss a toast automatically if duration is 0', () => {
|
||||||
|
service.show('Persistent toast', 'info', 0);
|
||||||
|
|
||||||
|
expect(service.toasts().length).toBe(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10000);
|
||||||
|
|
||||||
|
expect(service.toasts().length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
47
src/app/core/services/toast.service.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'danger' | 'info';
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ToastService {
|
||||||
|
private readonly toastsSignal = signal<Toast[]>([]);
|
||||||
|
readonly toasts = this.toastsSignal.asReadonly();
|
||||||
|
|
||||||
|
show(message: string, type: 'success' | 'danger' | 'info' = 'info', duration = 3000): string {
|
||||||
|
const id = Math.random().toString(36).substring(2, 9);
|
||||||
|
const newToast: Toast = { id, message, type, duration };
|
||||||
|
|
||||||
|
this.toastsSignal.update((toasts) => [...toasts, newToast]);
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.dismiss(id);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string, duration = 3000): string {
|
||||||
|
return this.show(message, 'info', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
danger(message: string, duration = 3000): string {
|
||||||
|
return this.show(message, 'danger', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
success(message: string, duration = 3000): string {
|
||||||
|
return this.show(message, 'success', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(id: string): void {
|
||||||
|
this.toastsSignal.update((toasts) => toasts.filter((t) => t.id !== id));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/app/features/componentes-test/componentes-test.routes.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { ReutilizablesTestPageComponent } from './pages/reutilizables-test-page/reutilizables-test-page.component';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: 'reutilizables',
|
||||||
|
component: ReutilizablesTestPageComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: 'reutilizables'
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,775 @@
|
|||||||
|
<main class="app-shell">
|
||||||
|
<section class="component-demo card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="eyebrow">Componentes reutilizables</span>
|
||||||
|
<h1>Button + Input</h1>
|
||||||
|
<p class="lead mb-4">Pagina dedicada a probar variantes de los componentes compartidos.</p>
|
||||||
|
|
||||||
|
<div class="button-showcase">
|
||||||
|
<div class="button-group">
|
||||||
|
<h2>Comunes</h2>
|
||||||
|
<div class="button-grid">
|
||||||
|
<app-button>Primary</app-button>
|
||||||
|
<app-button variant="secondary">Secondary</app-button>
|
||||||
|
<app-button variant="danger">Danger</app-button>
|
||||||
|
<app-button variant="danger-secondary">Danger secondary</app-button>
|
||||||
|
<app-button variant="cancel">Cancel</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<h2>Disabled</h2>
|
||||||
|
<div class="button-grid">
|
||||||
|
<app-button [disabled]="true">Primary</app-button>
|
||||||
|
<app-button variant="secondary" [disabled]="true">Secondary</app-button>
|
||||||
|
|
||||||
|
<app-button variant="danger" [disabled]="true">Danger</app-button>
|
||||||
|
<app-button variant="danger-secondary" [disabled]="true"> Danger secondary </app-button>
|
||||||
|
<app-button variant="cancel" [disabled]="true">Cancel</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<h2>Una letra</h2>
|
||||||
|
<div class="button-grid button-grid--single-char">
|
||||||
|
<app-button>A</app-button>
|
||||||
|
<app-button variant="secondary">B</app-button>
|
||||||
|
<app-button variant="danger">C</app-button>
|
||||||
|
<app-button variant="danger-secondary">D</app-button>
|
||||||
|
<app-button variant="cancel">E</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<h2>Toasts</h2>
|
||||||
|
<div class="button-grid">
|
||||||
|
<app-button (click)="triggerToast('info')">Trigger Info</app-button>
|
||||||
|
<app-button variant="secondary" (click)="triggerToast('success')">
|
||||||
|
Trigger Success
|
||||||
|
</app-button>
|
||||||
|
<app-button variant="danger" (click)="triggerToast('danger')">
|
||||||
|
Trigger Danger
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<h2>Toasts Persistentes (No se van)</h2>
|
||||||
|
<div class="button-grid">
|
||||||
|
<app-button (click)="triggerPersistentToast('info')"> Info Persistente </app-button>
|
||||||
|
<app-button variant="secondary" (click)="triggerPersistentToast('success')">
|
||||||
|
Success Persistente
|
||||||
|
</app-button>
|
||||||
|
<app-button variant="danger" (click)="triggerPersistentToast('danger')">
|
||||||
|
Danger Persistente
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<h2>Modal</h2>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
Demo del modal global con servicio, host y devolucion de resultado.
|
||||||
|
</p>
|
||||||
|
<div class="button-grid">
|
||||||
|
<app-button (click)="openBasicModal()">Abrir confirm modal</app-button>
|
||||||
|
<app-button variant="danger" (click)="openConfirmDeleteModal()">
|
||||||
|
Abrir confirm delete
|
||||||
|
</app-button>
|
||||||
|
<app-button variant="secondary" (click)="openLockedModal()">
|
||||||
|
Abrir modal bloqueado
|
||||||
|
</app-button>
|
||||||
|
<app-button variant="danger-secondary" (click)="openWideModal()">
|
||||||
|
Abrir modal ancho
|
||||||
|
</app-button>
|
||||||
|
<app-button (click)="openSimpleModal()"> Abrir simple modal </app-button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-showcase__result" data-testid="modal-last-result">
|
||||||
|
{{ lastModalResult }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="section-divider" />
|
||||||
|
|
||||||
|
<div class="icon-button-showcase">
|
||||||
|
<h2>Icon Buttons</h2>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Botones de icono pequenos con color base <code>#666666</code>, deshabilitados con
|
||||||
|
<code>#A0A0A0</code> y estados hover/active con color de marca (a excepcion de trash que
|
||||||
|
usa danger).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="icon-button-showcase__grid">
|
||||||
|
<div class="icon-button-showcase__row">
|
||||||
|
<span class="icon-button-showcase__label">Disabled</span>
|
||||||
|
<div class="icon-button-showcase__items">
|
||||||
|
<app-icon-button variant="user" [disabled]="true" />
|
||||||
|
<app-icon-button variant="trash" [disabled]="true" />
|
||||||
|
<app-icon-button variant="angle-left" [disabled]="true" />
|
||||||
|
<app-icon-button variant="angle-right" [disabled]="true" />
|
||||||
|
<app-icon-button variant="angle-up" [disabled]="true" />
|
||||||
|
<app-icon-button variant="angle-down" [disabled]="true" />
|
||||||
|
<app-icon-button variant="copy" [disabled]="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="icon-button-showcase__row">
|
||||||
|
<span class="icon-button-showcase__label">Normal</span>
|
||||||
|
<div class="icon-button-showcase__items">
|
||||||
|
<app-icon-button variant="user" />
|
||||||
|
<app-icon-button variant="trash" />
|
||||||
|
<app-icon-button variant="angle-left" />
|
||||||
|
<app-icon-button variant="angle-right" />
|
||||||
|
<app-icon-button variant="angle-up" />
|
||||||
|
<app-icon-button variant="angle-down" />
|
||||||
|
<app-icon-button variant="copy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="icon-button-showcase__row">
|
||||||
|
<span class="icon-button-showcase__label">Active / Hover</span>
|
||||||
|
<div class="icon-button-showcase__items">
|
||||||
|
<app-icon-button variant="user" class="hover-preview" />
|
||||||
|
<app-icon-button variant="trash" class="hover-preview" />
|
||||||
|
<app-icon-button variant="angle-left" class="hover-preview" />
|
||||||
|
<app-icon-button variant="angle-right" class="hover-preview" />
|
||||||
|
<app-icon-button variant="angle-up" class="hover-preview" />
|
||||||
|
<app-icon-button variant="angle-down" class="hover-preview" />
|
||||||
|
<app-icon-button variant="copy" class="hover-preview" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="icon-button-showcase mt-4">
|
||||||
|
<h2>Cart Icons</h2>
|
||||||
|
<p class="text-muted mb-4">Icono de carrito con badge opcional indicando cantidad.</p>
|
||||||
|
|
||||||
|
<div class="icon-button-showcase__grid">
|
||||||
|
<div class="icon-button-showcase__row">
|
||||||
|
<span class="icon-button-showcase__label">Disabled</span>
|
||||||
|
<div class="icon-button-showcase__items">
|
||||||
|
<app-cart-icon [disabled]="true" [quantity]="3" />
|
||||||
|
<app-cart-icon [disabled]="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="icon-button-showcase__row">
|
||||||
|
<span class="icon-button-showcase__label">Normal</span>
|
||||||
|
<div class="icon-button-showcase__items">
|
||||||
|
<app-cart-icon [quantity]="3" />
|
||||||
|
<app-cart-icon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="icon-button-showcase__row">
|
||||||
|
<span class="icon-button-showcase__label">Active / Hover</span>
|
||||||
|
<div class="icon-button-showcase__items">
|
||||||
|
<app-cart-icon [quantity]="3" class="hover-preview" />
|
||||||
|
<app-cart-icon class="hover-preview" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-grid">
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="search-input">Texto</label>
|
||||||
|
<app-input
|
||||||
|
id="search-input"
|
||||||
|
type="search"
|
||||||
|
placeholder="Buscar producto"
|
||||||
|
[(value)]="textValue"
|
||||||
|
[maxlength]="24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="number-input">Numero</label>
|
||||||
|
<app-input id="number-input" type="number" placeholder="0" [(value)]="numberValue" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="email-input">Email</label>
|
||||||
|
<app-input
|
||||||
|
id="email-input"
|
||||||
|
type="email"
|
||||||
|
placeholder="mail@empresa.com"
|
||||||
|
[(value)]="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="user-input">Usuario</label>
|
||||||
|
<app-input id="user-input" type="user" placeholder="Nombre de usuario" [(value)]="user" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="password-input">Password</label>
|
||||||
|
<app-input
|
||||||
|
id="password-input"
|
||||||
|
type="password-toggle"
|
||||||
|
placeholder="********"
|
||||||
|
[(value)]="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="date-input">Fecha</label>
|
||||||
|
<app-input id="date-input" type="date" [(value)]="date" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="period-input">Periodo</label>
|
||||||
|
<app-input id="period-input" type="period" [(value)]="period" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="amount-input">Moneda</label>
|
||||||
|
<app-input id="amount-input" type="currency" placeholder="0" [(value)]="amount" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="percent-input">Porcentaje</label>
|
||||||
|
<app-input id="percent-input" type="percent" placeholder="0" [(value)]="percent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="disabled-input">Deshabilitado</label>
|
||||||
|
<app-input
|
||||||
|
id="disabled-input"
|
||||||
|
placeholder="Campo deshabilitado"
|
||||||
|
[value]="disabledValue"
|
||||||
|
[disabled]="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="editable-input">Editable</label>
|
||||||
|
<app-input
|
||||||
|
id="editable-input"
|
||||||
|
type="editable"
|
||||||
|
[(value)]="editableValue"
|
||||||
|
[disabled]="editableDisabled"
|
||||||
|
(disabledChange)="editableDisabled = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="invalid-input">Invalido</label>
|
||||||
|
<app-input
|
||||||
|
id="invalid-input"
|
||||||
|
type="email"
|
||||||
|
placeholder="mail@empresa.com"
|
||||||
|
[(value)]="invalidEmail"
|
||||||
|
[invalid]="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-field">
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="file-input">Archivo</label>
|
||||||
|
<app-input
|
||||||
|
id="file-input"
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.png,.jpg"
|
||||||
|
[fileName]="fileName"
|
||||||
|
[clearTrigger]="clearTrigger"
|
||||||
|
(fileChange)="onFileChange($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-button variant="secondary" (click)="clearFile()"> Limpiar archivo </app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="section-divider" />
|
||||||
|
|
||||||
|
<div class="paginator-showcase">
|
||||||
|
<h2>Paginator</h2>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
Componente shared para navegar por paginas con primera, anterior, siguiente y ultima.
|
||||||
|
</p>
|
||||||
|
<app-paginator
|
||||||
|
[page]="currentPage"
|
||||||
|
[totalPages]="paginatorTotalPages"
|
||||||
|
(pageChange)="currentPage = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="section-divider" />
|
||||||
|
|
||||||
|
<div class="stepper-showcase">
|
||||||
|
<h2>Form Stepper</h2>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se
|
||||||
|
puede avanzar si el paso no es valido.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<app-stepper #demoStepper>
|
||||||
|
<!-- Paso 1: Siempre valido -->
|
||||||
|
<app-step label="Informacion Personal" [isValid]="true">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6 input-field">
|
||||||
|
<label for="stepper-name">Nombre</label>
|
||||||
|
<app-input id="stepper-name" placeholder="Juan Perez" [(value)]="user" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 input-field">
|
||||||
|
<label for="stepper-email">Email</label>
|
||||||
|
<app-input
|
||||||
|
id="stepper-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="mail@empresa.com"
|
||||||
|
[(value)]="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end mt-4">
|
||||||
|
<app-button (click)="demoStepper.next()">
|
||||||
|
Siguiente <i class="fa-solid fa-arrow-right ms-1"></i>
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</app-step>
|
||||||
|
|
||||||
|
<!-- Paso 2: Valido solo si testStep2Valid es true -->
|
||||||
|
<app-step label="Configuracion de Cuenta" [isValid]="testStep2Valid">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6 input-field">
|
||||||
|
<label for="stepper-password">Contraseña</label>
|
||||||
|
<app-input
|
||||||
|
id="stepper-password"
|
||||||
|
type="password-toggle"
|
||||||
|
placeholder="********"
|
||||||
|
[(value)]="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 input-field">
|
||||||
|
<label for="stepper-amount">Presupuesto mensual</label>
|
||||||
|
<app-input id="stepper-amount" type="currency" placeholder="0" [(value)]="amount" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stepper-validity-control mt-3 d-flex align-items-center gap-3">
|
||||||
|
<span class="text-muted small"> Simular validez del paso: </span>
|
||||||
|
<app-button
|
||||||
|
[variant]="testStep2Valid ? 'secondary' : 'primary'"
|
||||||
|
(click)="testStep2Valid = !testStep2Valid"
|
||||||
|
>
|
||||||
|
@if (testStep2Valid) {
|
||||||
|
<i class="fa-solid fa-check me-1"></i> Valido
|
||||||
|
} @else {
|
||||||
|
<i class="fa-solid fa-xmark me-1"></i> Invalido — Siguiente bloqueado
|
||||||
|
}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-4">
|
||||||
|
<app-button variant="secondary" (click)="demoStepper.previous()">
|
||||||
|
<i class="fa-solid fa-arrow-left me-1"></i> Volver
|
||||||
|
</app-button>
|
||||||
|
<app-button (click)="demoStepper.next()" [disabled]="!testStep2Valid">
|
||||||
|
Siguiente <i class="fa-solid fa-arrow-right ms-1"></i>
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</app-step>
|
||||||
|
|
||||||
|
<!-- Paso 3: Revision final, siempre valido -->
|
||||||
|
<app-step label="Revision y Confirmacion" [isValid]="true">
|
||||||
|
<div class="p-3 rounded bg-light">
|
||||||
|
<p class="mb-1"><strong>Nombre:</strong> {{ user }}</p>
|
||||||
|
<p class="mb-1"><strong>Email:</strong> {{ email }}</p>
|
||||||
|
<p class="mb-0"><strong>Presupuesto:</strong> {{ amount }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mt-4">
|
||||||
|
<app-button variant="secondary" (click)="demoStepper.previous()">
|
||||||
|
<i class="fa-solid fa-arrow-left me-1"></i> Volver
|
||||||
|
</app-button>
|
||||||
|
<app-button (click)="triggerToast('success')">
|
||||||
|
<i class="fa-solid fa-check me-1"></i> Confirmar
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</app-step>
|
||||||
|
</app-stepper>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="section-divider" />
|
||||||
|
|
||||||
|
<div class="accordion-showcase">
|
||||||
|
<h2>Accordion</h2>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Accordion reutilizable basado en las clases globales de Bootstrap. Por defecto mantiene un
|
||||||
|
solo item abierto.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<app-accordion>
|
||||||
|
<app-accordion-item title="¿Cómo realizo una compra?" [open]="true">
|
||||||
|
Elegí el producto, seleccioná sus variantes y agregalo al carrito. Desde allí podés
|
||||||
|
revisar el pedido y continuar al checkout.
|
||||||
|
</app-accordion-item>
|
||||||
|
|
||||||
|
<app-accordion-item title="¿Qué medios de pago están disponibles?">
|
||||||
|
Los medios de pago dependen de la configuración de cada tienda y se muestran durante el
|
||||||
|
checkout.
|
||||||
|
</app-accordion-item>
|
||||||
|
|
||||||
|
<app-accordion-item title="Item deshabilitado" [disabled]="true">
|
||||||
|
Este contenido no puede abrirse.
|
||||||
|
</app-accordion-item>
|
||||||
|
</app-accordion>
|
||||||
|
|
||||||
|
<h3 class="h5 mt-5 mb-3">Variante múltiple y flush</h3>
|
||||||
|
|
||||||
|
<app-accordion [allowMultiple]="true" [flush]="true">
|
||||||
|
<app-accordion-item title="Envíos">
|
||||||
|
La tienda informa los métodos y costos de envío disponibles para cada pedido.
|
||||||
|
</app-accordion-item>
|
||||||
|
|
||||||
|
<app-accordion-item title="Cambios y devoluciones">
|
||||||
|
Las condiciones de cambio y devolución son definidas por cada comercio.
|
||||||
|
</app-accordion-item>
|
||||||
|
</app-accordion>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="section-divider" />
|
||||||
|
|
||||||
|
<div class="cart-showcase">
|
||||||
|
<div class="cart-showcase__header">
|
||||||
|
<div>
|
||||||
|
<h2>Carrito mock</h2>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
Demo visual aislada del carrito con scroll a partir del cuarto item.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!cartVisible) {
|
||||||
|
<app-button variant="secondary" (click)="showCart()">
|
||||||
|
Volver a mostrar carrito
|
||||||
|
</app-button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (cartVisible) {
|
||||||
|
<div class="cart-showcase__viewport">
|
||||||
|
<app-cart
|
||||||
|
[showClose]="true"
|
||||||
|
[items]="cartMockItems"
|
||||||
|
[subtotal]="366000"
|
||||||
|
[discount]="66000"
|
||||||
|
[total]="300000"
|
||||||
|
[backgroundColor]="cartBackgroundColor"
|
||||||
|
(closed)="hideCart()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="component-demo card shadow-sm mt-4 configuracion-tennant">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="eyebrow">Tenant Configuration</span>
|
||||||
|
<h2 class="mb-4">Configuracion Tenant</h2>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6 mb-3 mb-md-0">
|
||||||
|
<div class="tenant-logo-card">
|
||||||
|
<div class="tenant-logo-card__label">Logo del Header</div>
|
||||||
|
<div class="tenant-logo-card__container header-bg">
|
||||||
|
@if (tenant()?.header_logo) {
|
||||||
|
<img
|
||||||
|
[src]="tenant()?.header_logo"
|
||||||
|
alt="Header Logo"
|
||||||
|
class="tenant-logo-card__img"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<div class="tenant-logo-card__placeholder">
|
||||||
|
<i class="fa-solid fa-image mb-2"></i>
|
||||||
|
<span>Sin Logo de Header</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="tenant-logo-card">
|
||||||
|
<div class="tenant-logo-card__label">Logo del Footer</div>
|
||||||
|
<div class="tenant-logo-card__container footer-bg">
|
||||||
|
@if (tenant()?.footer_logo) {
|
||||||
|
<img
|
||||||
|
[src]="tenant()?.footer_logo"
|
||||||
|
alt="Footer Logo"
|
||||||
|
class="tenant-logo-card__img"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<div class="tenant-logo-card__placeholder">
|
||||||
|
<i class="fa-solid fa-image mb-2"></i>
|
||||||
|
<span>Sin Logo de Footer</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="section-divider" />
|
||||||
|
|
||||||
|
<div class="colors-section">
|
||||||
|
<h3 class="mb-3">Colores de Marca</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="color-swatch-card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="color-swatch-card__preview"
|
||||||
|
[style.background-color]="'var(--tenant-primary)'"
|
||||||
|
></div>
|
||||||
|
<div class="color-swatch-card__body">
|
||||||
|
<span class="color-swatch-card__title">Primario</span>
|
||||||
|
<code class="color-swatch-card__value">
|
||||||
|
{{ tenant()?.primary_color || 'Default' }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="color-swatch-card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="color-swatch-card__preview"
|
||||||
|
[style.background-color]="'var(--tenant-secondary)'"
|
||||||
|
></div>
|
||||||
|
<div class="color-swatch-card__body">
|
||||||
|
<span class="color-swatch-card__title">Secundario</span>
|
||||||
|
<code class="color-swatch-card__value">
|
||||||
|
{{ tenant()?.secondary_color || 'Default' }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="color-swatch-card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="color-swatch-card__preview"
|
||||||
|
[style.background-color]="'var(--tenant-danger)'"
|
||||||
|
></div>
|
||||||
|
<div class="color-swatch-card__body">
|
||||||
|
<span class="color-swatch-card__title">Danger</span>
|
||||||
|
<code class="color-swatch-card__value">
|
||||||
|
{{ tenant()?.danger_color || 'Default' }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="color-swatch-card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="color-swatch-card__preview"
|
||||||
|
[style.background-color]="'var(--tenant-success)'"
|
||||||
|
></div>
|
||||||
|
<div class="color-swatch-card__body">
|
||||||
|
<span class="color-swatch-card__title">Success</span>
|
||||||
|
<code class="color-swatch-card__value">
|
||||||
|
{{ tenant()?.success_color || 'Default' }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="color-swatch-card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="color-swatch-card__preview"
|
||||||
|
[style.background-color]="'var(--tenant-header-bg)'"
|
||||||
|
></div>
|
||||||
|
<div class="color-swatch-card__body">
|
||||||
|
<span class="color-swatch-card__title">Header BG</span>
|
||||||
|
<code class="color-swatch-card__value">
|
||||||
|
{{ tenant()?.header_bg_color || 'Default' }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="color-swatch-card shadow-sm">
|
||||||
|
<div
|
||||||
|
class="color-swatch-card__preview"
|
||||||
|
[style.background-color]="'var(--tenant-footer-bg)'"
|
||||||
|
></div>
|
||||||
|
<div class="color-swatch-card__body">
|
||||||
|
<span class="color-swatch-card__title">Footer BG</span>
|
||||||
|
<code class="color-swatch-card__value">
|
||||||
|
{{ tenant()?.footer_bg_color || 'Default' }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="component-demo card shadow-sm mt-4" data-testid="main-carousel">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="eyebrow">Reusable Component</span>
|
||||||
|
<h2 class="mb-3">Main Carousel</h2>
|
||||||
|
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Carrusel panorámico compuesto únicamente por imágenes, con autoslide e indicadores.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<app-main-carousel
|
||||||
|
[images]="mainCarouselImages"
|
||||||
|
[imageAlts]="mainCarouselImageAlts"
|
||||||
|
[autoSlideInterval]="4000"
|
||||||
|
ariaLabel="Promociones de la tienda"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="component-demo card shadow-sm mt-4" data-testid="products-carousel">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="eyebrow">Reusable Component</span>
|
||||||
|
<h2 class="mb-3">Carousel de productos</h2>
|
||||||
|
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
El carousel controla únicamente el desplazamiento. Cada ítem se define externamente con un
|
||||||
|
template y en este ejemplo renderiza productos con imágenes mockeadas.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ng-template #productCarouselItem let-product let-index="index">
|
||||||
|
<div class="carousel-product" [attr.data-carousel-index]="index">
|
||||||
|
<span class="carousel-product__mock-id">ITEM {{ product.id }}</span>
|
||||||
|
<app-product-column-with-image
|
||||||
|
[imageUrl]="product.imageUrl"
|
||||||
|
[title]="product.title"
|
||||||
|
[originalPrice]="product.originalPrice"
|
||||||
|
[discount]="product.discount"
|
||||||
|
[transferPrice]="product.transferPrice"
|
||||||
|
(buy)="onProductBuy(product.title)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<app-carousel
|
||||||
|
[items]="carouselProducts"
|
||||||
|
[itemTemplate]="productCarouselItem"
|
||||||
|
[trackBy]="trackCarouselProduct"
|
||||||
|
[gap]="16"
|
||||||
|
[circular]="true"
|
||||||
|
ariaLabel="Productos con imágenes de prueba"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="component-demo card shadow-sm mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="eyebrow">Reusable Component</span>
|
||||||
|
<h2 class="mb-4">Tarjetas de Producto (Column With Image)</h2>
|
||||||
|
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se presentan dentro
|
||||||
|
de una grilla de Bootstrap.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
@for (product of testProducts; track product.title) {
|
||||||
|
<div class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch">
|
||||||
|
<app-product-column-with-image
|
||||||
|
[imageUrl]="product.imageUrl"
|
||||||
|
[title]="product.title"
|
||||||
|
[originalPrice]="product.originalPrice"
|
||||||
|
[discount]="product.discount"
|
||||||
|
[transferPrice]="product.transferPrice"
|
||||||
|
(buy)="onProductBuy(product.title)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="component-demo card shadow-sm mt-4" data-testid="location-map-demo">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="eyebrow">Reusable Component</span>
|
||||||
|
<h2 class="mb-2">Mapa de ubicaciones</h2>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Prueba de Leaflet con múltiples puntos, encuadre automático y acceso a indicaciones.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<app-location-map
|
||||||
|
[locations]="mapLocations"
|
||||||
|
[markerColor]="tenant()?.primary_color ?? '#24a9e0'"
|
||||||
|
mapStyle="minimal-light"
|
||||||
|
ariaLabel="Mapa de puntos de atención en Rosario"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="component-demo card shadow-sm mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="eyebrow">Reusable Component</span>
|
||||||
|
<h2 class="mb-4">Tarjetas de Producto (Row Layout)</h2>
|
||||||
|
|
||||||
|
<p class="text-muted mb-4">Diseño horizontal que ocupa el 100% del ancho disponible.</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 mb-4 d-flex align-items-stretch">
|
||||||
|
<app-product-row-card
|
||||||
|
[title]="testRowProduct.title"
|
||||||
|
[description]="testRowProduct.description"
|
||||||
|
[price]="testRowProduct.price"
|
||||||
|
[variants]="testRowProduct.variants"
|
||||||
|
(buy)="onProductBuy(testRowProduct.title)"
|
||||||
|
(addToCart)="triggerToast('success')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="component-demo card shadow-sm mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="eyebrow">Reusable Component</span>
|
||||||
|
<h2 class="mb-4">Tarjetas de Producto (Vertical With Cart Layout)</h2>
|
||||||
|
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Diseño vertical con selector de cantidad y acciones de compra y carrito.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row justify-content-center justify-content-md-start">
|
||||||
|
<div class="col-12 col-sm-9 col-md-6 col-lg-5 col-xl-4 mb-4 d-flex align-items-stretch">
|
||||||
|
<app-product-vertical-with-cart-card
|
||||||
|
[title]="testVerticalWithCartProduct.title"
|
||||||
|
[description]="testVerticalWithCartProduct.description"
|
||||||
|
[price]="testVerticalWithCartProduct.price"
|
||||||
|
(buy)="onProductBuy(testVerticalWithCartProduct.title)"
|
||||||
|
(addToCart)="triggerToast('success')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-store-section title="PRODUCTOS" class="component-demo store-section-demo mt-5">
|
||||||
|
<div class="row">
|
||||||
|
@for (product of testProducts; track product.title) {
|
||||||
|
<div
|
||||||
|
class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item"
|
||||||
|
>
|
||||||
|
<app-product-column-with-image
|
||||||
|
[imageUrl]="product.imageUrl"
|
||||||
|
[title]="product.title"
|
||||||
|
[originalPrice]="product.originalPrice"
|
||||||
|
[discount]="product.discount"
|
||||||
|
[transferPrice]="product.transferPrice"
|
||||||
|
(buy)="onProductBuy(product.title)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</app-store-section>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-demo {
|
||||||
|
width: min(100%, 64rem);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-section-demo {
|
||||||
|
width: min(100%, 64rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-product {
|
||||||
|
position: relative;
|
||||||
|
width: clamp(15rem, 30vw, 18rem);
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&__mock-id {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 0.35rem 0.55rem;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
background: rgb(20 20 20 / 82%);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
app-product-column-with-image {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-showcase {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-showcase__result {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border: 1px dashed rgba(32, 32, 32, 0.14);
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
background: linear-gradient(180deg, rgba(248, 248, 248, 0.92), rgba(255, 255, 255, 0.98));
|
||||||
|
color: #495057;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .modal-demo-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .modal-demo-content__eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .modal-demo-content__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #202020;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .modal-demo-content__description {
|
||||||
|
margin: 0;
|
||||||
|
color: #505050;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .modal-demo-content__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginator-showcase {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-showcase {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-showcase__viewport {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 504px;
|
||||||
|
height: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-showcase__header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-grid--single-char app-button {
|
||||||
|
min-width: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-section-demo__item {
|
||||||
|
app-product-column-with-image {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.input-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.carousel-product {
|
||||||
|
width: min(18rem, calc(100vw - 5rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-showcase__header {
|
||||||
|
align-items: start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-showcase__result {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.configuracion-tennant {
|
||||||
|
margin-top: 2rem;
|
||||||
|
|
||||||
|
.tenant-logo-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 4px rgba(0, 0, 0, 0.05),
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__img {
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
&__placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 2rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bg {
|
||||||
|
background-color: var(--tenant-header-bg, #f8f9fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bg {
|
||||||
|
background-color: var(--tenant-footer-bg, #f8f9fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #ffffff;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview {
|
||||||
|
height: 80px;
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #212529;
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button-showcase {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
background-color: #eaeaea;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #495057;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { TestBed, getTestBed } from '@angular/core/testing';
|
||||||
|
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ModalService } from '../../../../core/services/modal.service';
|
||||||
|
import { Tenant } from '../../../../core/services/tenant.interface';
|
||||||
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
|
import { ReutilizablesTestPageComponent } from './reutilizables-test-page.component';
|
||||||
|
|
||||||
|
const tenant: Tenant = {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'test',
|
||||||
|
nombre: 'Test Tenant',
|
||||||
|
dominio: 'localhost',
|
||||||
|
primary_color: '#6376F3',
|
||||||
|
secondary_color: '#A0A0A0',
|
||||||
|
danger_color: '#FF8888',
|
||||||
|
success_color: '#198754',
|
||||||
|
header_bg_color: '#313131',
|
||||||
|
footer_bg_color: '#313131',
|
||||||
|
header_logo: 'https://example.com/header.png',
|
||||||
|
footer_logo: 'https://example.com/footer.png',
|
||||||
|
categories: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function createTenantServiceStub(currentTenant: Tenant | null) {
|
||||||
|
const tenantState = signal(currentTenant);
|
||||||
|
const statusState = signal<'ready' | 'not-found'>('ready');
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenant: tenantState.asReadonly(),
|
||||||
|
status: statusState.asReadonly(),
|
||||||
|
getTenant: () => tenantState(),
|
||||||
|
bootstrap: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToastServiceStub() {
|
||||||
|
return {
|
||||||
|
success: vi.fn(),
|
||||||
|
danger: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createModalServiceStub() {
|
||||||
|
return {
|
||||||
|
open: vi.fn(),
|
||||||
|
openConfirm: vi.fn().mockReturnValue(of(true)),
|
||||||
|
openConfirmDelete: vi.fn().mockReturnValue(of(false)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ReutilizablesTestPageComponent', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
try {
|
||||||
|
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
|
||||||
|
} catch {
|
||||||
|
// Test environment may already be initialized by another setup entrypoint.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the tenant configuration section with logo sources and swatch colors', async () => {
|
||||||
|
const modalServiceStub = createModalServiceStub();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ReutilizablesTestPageComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: createTenantServiceStub(tenant),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: createToastServiceStub(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ModalService,
|
||||||
|
useValue: modalServiceStub,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const sectionTitle = element.querySelector('.configuracion-tennant h2');
|
||||||
|
|
||||||
|
expect(sectionTitle?.textContent).toContain('Configuracion Tenant');
|
||||||
|
|
||||||
|
const headerImg = element.querySelector(
|
||||||
|
'.tenant-logo-card img[alt="Header Logo"]',
|
||||||
|
) as HTMLImageElement;
|
||||||
|
expect(headerImg?.src).toBe(tenant.header_logo);
|
||||||
|
|
||||||
|
const footerImg = element.querySelector(
|
||||||
|
'.tenant-logo-card img[alt="Footer Logo"]',
|
||||||
|
) as HTMLImageElement;
|
||||||
|
expect(footerImg?.src).toBe(tenant.footer_logo);
|
||||||
|
|
||||||
|
expect(element.textContent).toContain(tenant.primary_color);
|
||||||
|
expect(element.textContent).toContain(tenant.secondary_color);
|
||||||
|
expect(element.textContent).toContain(tenant.danger_color);
|
||||||
|
expect(element.textContent).toContain(tenant.success_color);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the paginator demo with the initial page status', async () => {
|
||||||
|
const modalServiceStub = createModalServiceStub();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ReutilizablesTestPageComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: createTenantServiceStub(tenant),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: createToastServiceStub(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ModalService,
|
||||||
|
useValue: modalServiceStub,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.querySelector('app-paginator')).not.toBeNull();
|
||||||
|
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
|
||||||
|
'1/8',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the reusable map demo with all configured locations', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ReutilizablesTestPageComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: createTenantServiceStub(tenant),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: createToastServiceStub(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ModalService,
|
||||||
|
useValue: createModalServiceStub(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const mapDemo = fixture.nativeElement.querySelector(
|
||||||
|
'[data-testid="location-map-demo"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
const accessibleLocations = mapDemo.querySelectorAll('app-location-map .visually-hidden li');
|
||||||
|
|
||||||
|
expect(mapDemo.querySelector('app-location-map')).not.toBeNull();
|
||||||
|
expect(accessibleLocations).toHaveLength(3);
|
||||||
|
expect(mapDemo.textContent).toContain('Gigante de Arroyito');
|
||||||
|
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the carousel demo with mocked product images', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ReutilizablesTestPageComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: createTenantServiceStub(tenant),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: createToastServiceStub(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ModalService,
|
||||||
|
useValue: createModalServiceStub(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const carouselDemo = element.querySelector('[data-testid="products-carousel"]');
|
||||||
|
const carouselItems = carouselDemo?.querySelectorAll('[data-carousel-index]');
|
||||||
|
const productImages = carouselDemo?.querySelectorAll('app-product-column-with-image img');
|
||||||
|
|
||||||
|
expect(carouselDemo?.querySelector('app-carousel')).not.toBeNull();
|
||||||
|
expect(carouselItems).toHaveLength(6);
|
||||||
|
expect(productImages).toHaveLength(6);
|
||||||
|
expect(productImages?.[0].getAttribute('src')).toContain('/images/carousel-mochila-roja.webp');
|
||||||
|
expect(productImages?.[1].getAttribute('src')).toContain(
|
||||||
|
'/images/carousel-auriculares-amarillos.webp',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
new Set(Array.from(productImages ?? [], (image) => image.getAttribute('src'))).size,
|
||||||
|
).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the main carousel demo with remote images', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ReutilizablesTestPageComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: createTenantServiceStub(tenant),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: createToastServiceStub(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ModalService,
|
||||||
|
useValue: createModalServiceStub(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const carouselDemo = element.querySelector('[data-testid="main-carousel"]');
|
||||||
|
const images = carouselDemo?.querySelectorAll('.main-carousel__image');
|
||||||
|
const indicators = carouselDemo?.querySelectorAll('.main-carousel__indicator');
|
||||||
|
|
||||||
|
expect(carouselDemo?.querySelector('app-main-carousel')).not.toBeNull();
|
||||||
|
expect(images).toHaveLength(3);
|
||||||
|
expect(indicators).toHaveLength(3);
|
||||||
|
expect(images?.[0].getAttribute('src')).toContain('images.unsplash.com');
|
||||||
|
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the modal showcase and opens the confirm demos from the buttons', async () => {
|
||||||
|
const modalServiceStub = createModalServiceStub();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ReutilizablesTestPageComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: createTenantServiceStub(tenant),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: createToastServiceStub(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ModalService,
|
||||||
|
useValue: modalServiceStub,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const buttons = Array.from(element.querySelectorAll('.button-group app-button button'));
|
||||||
|
const confirmButton = buttons.find((button) =>
|
||||||
|
button.textContent?.includes('Abrir confirm modal'),
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
const deleteButton = buttons.find((button) =>
|
||||||
|
button.textContent?.includes('Abrir confirm delete'),
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
|
||||||
|
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
|
||||||
|
'Todavia no se abrio ningun modal.',
|
||||||
|
);
|
||||||
|
|
||||||
|
confirmButton.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
deleteButton.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(modalServiceStub.openConfirm).toHaveBeenCalledWith({
|
||||||
|
title: 'Confirmar accion',
|
||||||
|
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
|
||||||
|
confirmLabel: 'Confirmar',
|
||||||
|
});
|
||||||
|
expect(modalServiceStub.openConfirmDelete).toHaveBeenCalledWith({
|
||||||
|
title: 'Eliminar producto',
|
||||||
|
content:
|
||||||
|
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
|
||||||
|
confirmLabel: 'Eliminar',
|
||||||
|
});
|
||||||
|
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
|
||||||
|
'Resultado: false',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the visible result when the confirm modal resolves to true', async () => {
|
||||||
|
const modalServiceStub = createModalServiceStub();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ReutilizablesTestPageComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: createTenantServiceStub(tenant),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: createToastServiceStub(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ModalService,
|
||||||
|
useValue: modalServiceStub,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const confirmButton = Array.from(
|
||||||
|
element.querySelectorAll('.button-group app-button button'),
|
||||||
|
).find((button) => button.textContent?.includes('Abrir confirm modal')) as HTMLButtonElement;
|
||||||
|
|
||||||
|
confirmButton.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
|
||||||
|
'Resultado: true',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||||
|
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
|
||||||
|
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
|
||||||
|
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
|
||||||
|
import { InputComponent } from '../../../../shared/components/input/input.component';
|
||||||
|
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
|
||||||
|
import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component';
|
||||||
|
import { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component';
|
||||||
|
import { ProductVerticalWithCartCardComponent } from '../../../../shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component';
|
||||||
|
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
|
||||||
|
import { ModalService } from '../../../../core/services/modal.service';
|
||||||
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
|
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
|
||||||
|
import { StepComponent } from '../../../../shared/components/stepper/step.component';
|
||||||
|
import { CarouselComponent } from '../../../../shared/components/carousel/carousel.component';
|
||||||
|
import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-carousel.component';
|
||||||
|
import { AccordionComponent } from '../../../../shared/components/accordion/accordion.component';
|
||||||
|
import { AccordionItemComponent } from '../../../../shared/components/accordion/accordion-item.component';
|
||||||
|
import {
|
||||||
|
LocationMapComponent,
|
||||||
|
MapLocation,
|
||||||
|
} from '../../../../shared/components/location-map/location-map.component';
|
||||||
|
|
||||||
|
interface CarouselProductMock {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
originalPrice: number;
|
||||||
|
discount: number | null;
|
||||||
|
transferPrice: number;
|
||||||
|
imageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-reutilizables-test-page',
|
||||||
|
imports: [
|
||||||
|
ButtonComponent,
|
||||||
|
CartComponent,
|
||||||
|
InputComponent,
|
||||||
|
PaginatorComponent,
|
||||||
|
ProductColumnWithImageComponent,
|
||||||
|
ProductRowCardComponent,
|
||||||
|
ProductVerticalWithCartCardComponent,
|
||||||
|
StoreSectionComponent,
|
||||||
|
IconButtonComponent,
|
||||||
|
CartIconComponent,
|
||||||
|
StepperComponent,
|
||||||
|
StepComponent,
|
||||||
|
CarouselComponent,
|
||||||
|
MainCarouselComponent,
|
||||||
|
AccordionComponent,
|
||||||
|
AccordionItemComponent,
|
||||||
|
LocationMapComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './reutilizables-test-page.component.html',
|
||||||
|
styleUrl: './reutilizables-test-page.component.scss',
|
||||||
|
})
|
||||||
|
export class ReutilizablesTestPageComponent {
|
||||||
|
private readonly tenantService = inject(TenantService);
|
||||||
|
private readonly toastService = inject(ToastService);
|
||||||
|
private readonly modalService = inject(ModalService);
|
||||||
|
|
||||||
|
protected testStep1Valid = true;
|
||||||
|
protected testStep2Valid = false;
|
||||||
|
protected readonly tenant = this.tenantService.tenant;
|
||||||
|
protected textValue = 'Auriculares';
|
||||||
|
protected numberValue = '24';
|
||||||
|
protected email = 'usuario@empresa.com';
|
||||||
|
protected user = 'Juan Perez';
|
||||||
|
protected password = '12345678';
|
||||||
|
protected date = '2026-06-24';
|
||||||
|
protected amount = '125000';
|
||||||
|
protected percent = '12';
|
||||||
|
protected period = '2026-06';
|
||||||
|
protected editableValue = 'Solo lectura al cargar';
|
||||||
|
protected disabledValue = 'No editable';
|
||||||
|
protected invalidEmail = 'correo-invalido';
|
||||||
|
protected fileName = '';
|
||||||
|
protected clearTrigger = 0;
|
||||||
|
protected editableDisabled = true;
|
||||||
|
protected currentPage = 1;
|
||||||
|
protected cartVisible = true;
|
||||||
|
protected lastModalResult = 'Todavia no se abrio ningun modal.';
|
||||||
|
protected readonly paginatorTotalPages = 8;
|
||||||
|
protected readonly cartBackgroundColor = '#ffffff';
|
||||||
|
protected readonly mapLocations: readonly MapLocation[] = [
|
||||||
|
{
|
||||||
|
id: 'gigante-de-arroyito',
|
||||||
|
label: 'Gigante de Arroyito',
|
||||||
|
address: 'Av. Génova 640, Rosario',
|
||||||
|
latitude: -32.913997,
|
||||||
|
longitude: -60.674567,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'punto-centro',
|
||||||
|
label: 'Punto Centro',
|
||||||
|
address: 'Centro, Rosario',
|
||||||
|
latitude: -32.94682,
|
||||||
|
longitude: -60.63932,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'punto-sur',
|
||||||
|
label: 'Punto Sur',
|
||||||
|
address: 'Zona sur, Rosario',
|
||||||
|
latitude: -32.97151,
|
||||||
|
longitude: -60.65064,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
protected readonly mainCarouselImages = [
|
||||||
|
'https://images.unsplash.com/photo-1441986300917-64674bd600d8?auto=format&fit=crop&w=1600&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1529139574466-a303027c1d8b?auto=format&fit=crop&w=1600&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=1600&q=80',
|
||||||
|
];
|
||||||
|
protected readonly mainCarouselImageAlts = [
|
||||||
|
'Interior de una tienda de indumentaria',
|
||||||
|
'Colección de moda en exteriores',
|
||||||
|
'Percheros con prendas de distintos colores',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected readonly testProducts = [
|
||||||
|
{
|
||||||
|
title: 'Pantalon recto de scuba negro',
|
||||||
|
originalPrice: 95000,
|
||||||
|
discount: 20,
|
||||||
|
transferPrice: 74800,
|
||||||
|
imageUrl: '/images/pantalon-scuba.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Zapatillas de running azul',
|
||||||
|
originalPrice: 120000,
|
||||||
|
discount: 15,
|
||||||
|
transferPrice: 98000,
|
||||||
|
imageUrl: '/images/zapatillas-running.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Remera basica blanca (Sin Descuento)',
|
||||||
|
originalPrice: 32000,
|
||||||
|
discount: null,
|
||||||
|
transferPrice: 30000,
|
||||||
|
imageUrl: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
protected readonly carouselProducts: CarouselProductMock[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '01 · Mochila roja',
|
||||||
|
originalPrice: 85000,
|
||||||
|
discount: 20,
|
||||||
|
transferPrice: 68000,
|
||||||
|
imageUrl: '/images/carousel-mochila-roja.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '02 · Auriculares amarillos',
|
||||||
|
originalPrice: 120000,
|
||||||
|
discount: 15,
|
||||||
|
transferPrice: 102000,
|
||||||
|
imageUrl: '/images/carousel-auriculares-amarillos.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '03 · Reloj verde',
|
||||||
|
originalPrice: 96000,
|
||||||
|
discount: 10,
|
||||||
|
transferPrice: 86400,
|
||||||
|
imageUrl: '/images/carousel-reloj-verde.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '04 · Cámara violeta',
|
||||||
|
originalPrice: 135000,
|
||||||
|
discount: null,
|
||||||
|
transferPrice: 128000,
|
||||||
|
imageUrl: '/images/carousel-camara-violeta.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: '05 · Zapatillas naranjas',
|
||||||
|
originalPrice: 110000,
|
||||||
|
discount: 25,
|
||||||
|
transferPrice: 82500,
|
||||||
|
imageUrl: '/images/carousel-zapatillas-naranjas.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: '06 · Campera azul',
|
||||||
|
originalPrice: 142000,
|
||||||
|
discount: 12,
|
||||||
|
transferPrice: 121000,
|
||||||
|
imageUrl: '/images/carousel-campera-azul.webp',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
protected readonly trackCarouselProduct = (
|
||||||
|
_index: number,
|
||||||
|
product: CarouselProductMock,
|
||||||
|
): number => product.id;
|
||||||
|
|
||||||
|
protected readonly testRowProduct = {
|
||||||
|
title: 'ENTRADA GENERAL',
|
||||||
|
description:
|
||||||
|
'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.',
|
||||||
|
price: 10000,
|
||||||
|
variants: [
|
||||||
|
{ label: '10 de Octubre', value: '10-oct' },
|
||||||
|
{ label: '11 de Octubre', value: '11-oct' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
protected readonly testVerticalWithCartProduct = {
|
||||||
|
title: 'PANCHO',
|
||||||
|
description: 'Pancho con aderezo a elección.',
|
||||||
|
price: 11500,
|
||||||
|
};
|
||||||
|
|
||||||
|
protected readonly cartMockItems: CartItemMock[] = [
|
||||||
|
{
|
||||||
|
imageUrl: null,
|
||||||
|
product: 'Calza termica unisex con proceso sense y cintura con mayor agarre',
|
||||||
|
originalPrice: 95000,
|
||||||
|
discountedPrice: 76000,
|
||||||
|
discountPercentage: 20,
|
||||||
|
quantity: 1,
|
||||||
|
attributes: [
|
||||||
|
{ label: 'Color', value: 'Beige' },
|
||||||
|
{ label: 'Talle', value: 'S' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imageUrl: null,
|
||||||
|
product: 'Pantalon recto de scuba negro',
|
||||||
|
originalPrice: null,
|
||||||
|
discountedPrice: 89000,
|
||||||
|
discountPercentage: null,
|
||||||
|
quantity: 1,
|
||||||
|
attributes: [
|
||||||
|
{ label: 'Color', value: 'Beige' },
|
||||||
|
{ label: 'Talle', value: 'S' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imageUrl: null,
|
||||||
|
product: 'Top de supplex color habano',
|
||||||
|
originalPrice: 98000,
|
||||||
|
discountedPrice: 85000,
|
||||||
|
discountPercentage: 20,
|
||||||
|
quantity: 1,
|
||||||
|
attributes: [
|
||||||
|
{ label: 'Color', value: 'Negro' },
|
||||||
|
{ label: 'Talle', value: 'XL' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
imageUrl: null,
|
||||||
|
product: 'Buzo oversize canguro gris',
|
||||||
|
originalPrice: 72000,
|
||||||
|
discountedPrice: 69000,
|
||||||
|
discountPercentage: 5,
|
||||||
|
quantity: 2,
|
||||||
|
attributes: [
|
||||||
|
{ label: 'Color', value: 'Gris' },
|
||||||
|
{ label: 'Talle', value: 'M' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
protected onProductBuy(productTitle: string): void {
|
||||||
|
console.log(`Comprando: ${productTitle}`);
|
||||||
|
alert(`Comprando: ${productTitle}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onFileChange(file: File | null): void {
|
||||||
|
this.fileName = file?.name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected clearFile(): void {
|
||||||
|
this.fileName = '';
|
||||||
|
this.clearTrigger += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected showCart(): void {
|
||||||
|
this.cartVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected hideCart(): void {
|
||||||
|
this.cartVisible = false;
|
||||||
|
this.toastService.info('Se disparo el evento de cerrado del carrito mock.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected triggerToast(type: 'success' | 'danger' | 'info'): void {
|
||||||
|
if (type === 'success') {
|
||||||
|
this.toastService.success('Operacion exitosa. El producto se ha guardado correctamente.');
|
||||||
|
} else if (type === 'danger') {
|
||||||
|
this.toastService.danger('Alerta de error. Hubo un problema al procesar la solicitud.');
|
||||||
|
} else if (type === 'info') {
|
||||||
|
this.toastService.info('Informacion del sistema: Hay una nueva actualizacion disponible.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected triggerPersistentToast(type: 'success' | 'danger' | 'info'): void {
|
||||||
|
if (type === 'success') {
|
||||||
|
this.toastService.success('Exito persistente. Esta alerta no se cerrara sola.', 0);
|
||||||
|
} else if (type === 'danger') {
|
||||||
|
this.toastService.danger(
|
||||||
|
'Error persistente. Por favor, atienda este problema y cierrelo manualmente.',
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
} else if (type === 'info') {
|
||||||
|
this.toastService.info('Informacion persistente: Mantendremos este aviso en pantalla.', 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openBasicModal(): void {
|
||||||
|
this.openConfirmModal({
|
||||||
|
title: 'Confirmar accion',
|
||||||
|
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
|
||||||
|
confirmLabel: 'Confirmar',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openConfirmDeleteModal(): void {
|
||||||
|
this.openConfirmDelete({
|
||||||
|
title: 'Eliminar producto',
|
||||||
|
content:
|
||||||
|
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
|
||||||
|
confirmLabel: 'Eliminar',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openLockedModal(): void {
|
||||||
|
this.openConfirmModal({
|
||||||
|
title: 'Modal bloqueado',
|
||||||
|
content: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
|
||||||
|
confirmLabel: 'Entendido',
|
||||||
|
closeOnBackdrop: false,
|
||||||
|
closeOnEscape: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openWideModal(): void {
|
||||||
|
this.openConfirmModal({
|
||||||
|
title: 'Modal ancho',
|
||||||
|
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
|
||||||
|
confirmLabel: 'Seguir',
|
||||||
|
size: 'xl',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openSimpleModal(): void {
|
||||||
|
this.modalService
|
||||||
|
.openSimple({
|
||||||
|
title: 'Mensaje del sistema',
|
||||||
|
content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
|
||||||
|
buttonLabel: 'Entendido',
|
||||||
|
})
|
||||||
|
.subscribe(() => {
|
||||||
|
this.lastModalResult = 'Simple modal cerrado';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void {
|
||||||
|
this.modalService.openConfirm(config).subscribe((confirmed) => {
|
||||||
|
this.lastModalResult = `Resultado: ${confirmed}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private openConfirmDelete(config: Parameters<ModalService['openConfirmDelete']>[0]): void {
|
||||||
|
this.modalService.openConfirmDelete(config).subscribe((confirmed) => {
|
||||||
|
this.lastModalResult = `Resultado: ${confirmed}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<div class="attribute-selector">
|
||||||
|
@for (attribute of attributes(); track attribute.id) {
|
||||||
|
<div class="attribute-selector__row">
|
||||||
|
<span class="attribute-selector__label">{{ attribute.nombre }}:</span>
|
||||||
|
|
||||||
|
<div class="attribute-selector__options">
|
||||||
|
@for (option of attribute.options; track option.id) {
|
||||||
|
@if (isColorOption(option)) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="attribute-selector__swatch"
|
||||||
|
[class.attribute-selector__swatch--selected]="hasSelectedOption(attribute, option)"
|
||||||
|
[class.attribute-selector__swatch--disabled]="!availableOptions()[attribute.id][option.id]"
|
||||||
|
[style.background-color]="getOptionSwatchColor(option)"
|
||||||
|
[attr.aria-label]="option.label"
|
||||||
|
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
|
||||||
|
[title]="option.label"
|
||||||
|
[disabled]="!availableOptions()[attribute.id][option.id]"
|
||||||
|
(click)="selectAttributeOption(attribute, option)"
|
||||||
|
>
|
||||||
|
<span class="visually-hidden">{{ option.label }}</span>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="attribute-selector__text-option"
|
||||||
|
[class.attribute-selector__text-option--selected]="hasSelectedOption(attribute, option)"
|
||||||
|
[class.attribute-selector__text-option--disabled]="!availableOptions()[attribute.id][option.id]"
|
||||||
|
[attr.aria-pressed]="hasSelectedOption(attribute, option)"
|
||||||
|
[disabled]="!availableOptions()[attribute.id][option.id]"
|
||||||
|
(click)="selectAttributeOption(attribute, option)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
.attribute-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #A0A0A0;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__swatch {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
border-color: var(--tenant-primary);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(255, 255, 255, 1),
|
||||||
|
0 0 0 3px var(--tenant-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text-option {
|
||||||
|
border: 1px solid #dcdcdc;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #666666;
|
||||||
|
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
background: var(--tenant-primary);
|
||||||
|
border-color: var(--tenant-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
color: #b0b0b0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { ProductAttribute } from '../../../../core/services/catalog/catalog.interface';
|
||||||
|
import { ProductAttributeSelectorComponent } from './product-attribute-selector.component';
|
||||||
|
|
||||||
|
describe('ProductAttributeSelectorComponent', () => {
|
||||||
|
const sizeAttribute: ProductAttribute = {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'size',
|
||||||
|
nombre: 'Size',
|
||||||
|
is_required: true,
|
||||||
|
metadata_schema: null,
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ id: 10, value: 'S', label: 'S', sort_order: 0, metadata: null },
|
||||||
|
{ id: 20, value: 'M', label: 'M', sort_order: 1, metadata: null },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ProductAttributeSelectorComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an unlimited option available when maximum quantity is null', () => {
|
||||||
|
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
|
||||||
|
fixture.componentRef.setInput('attributes', [sizeAttribute]);
|
||||||
|
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
|
||||||
|
fixture.componentRef.setInput('variants', [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
stock_tecnico: null,
|
||||||
|
values: { size: 'S' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const buttons = Array.from(
|
||||||
|
fixture.nativeElement.querySelectorAll('.attribute-selector__text-option'),
|
||||||
|
) as HTMLButtonElement[];
|
||||||
|
expect(buttons[0].disabled).toBe(false);
|
||||||
|
expect(buttons[1].disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables tracked options without available stock', () => {
|
||||||
|
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
|
||||||
|
fixture.componentRef.setInput('attributes', [sizeAttribute]);
|
||||||
|
fixture.componentRef.setInput('inventoryPolicy', 'tracked');
|
||||||
|
fixture.componentRef.setInput('variants', [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
stock_tecnico: 0,
|
||||||
|
values: { size: 'S' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
stock_tecnico: 2,
|
||||||
|
values: { size: 'M' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const buttons = Array.from(
|
||||||
|
fixture.nativeElement.querySelectorAll('.attribute-selector__text-option'),
|
||||||
|
) as HTMLButtonElement[];
|
||||||
|
expect(buttons[0].disabled).toBe(true);
|
||||||
|
expect(buttons[1].disabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||