Compare commits
229 Commits
prueba/cac
...
homologaci
| Author | SHA1 | Date | |
|---|---|---|---|
| 8aea54959f | |||
| 12ca45bdd8 | |||
| fa6fc53f8d | |||
| 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 |
26
angular.json
@@ -2,7 +2,8 @@
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
@@ -29,9 +30,7 @@
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"styles": ["node_modules/leaflet/dist/leaflet.css", "src/styles.scss"],
|
||||
"server": "src/main.server.ts",
|
||||
"outputMode": "server",
|
||||
"security": {
|
||||
@@ -58,9 +57,22 @@
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"local": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"homo": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.homo.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
@@ -73,6 +85,12 @@
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "shopit-front:build:development"
|
||||
},
|
||||
"local": {
|
||||
"buildTarget": "shopit-front:build:local"
|
||||
},
|
||||
"homo": {
|
||||
"buildTarget": "shopit-front:build:homo"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
|
||||
323
package-lock.json
generated
@@ -19,6 +19,8 @@
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"express": "^5.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"qrcode": "1.5.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -27,6 +29,7 @@
|
||||
"@angular/cli": "^22.0.4",
|
||||
"@angular/compiler-cli": "^22.0.0",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^20.17.19",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
@@ -3815,6 +3818,13 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
@@ -3822,6 +3832,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
@@ -4394,6 +4414,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001799",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
||||
@@ -4578,6 +4607,24 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||
@@ -4759,6 +4806,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
@@ -4786,6 +4842,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
@@ -5237,6 +5299,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -5306,7 +5381,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -5881,6 +5955,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/listr2": {
|
||||
"version": "10.2.1",
|
||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
|
||||
@@ -5927,6 +6007,18 @@
|
||||
"@lmdb/lmdb-win32-x64": "3.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/log-symbols": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
||||
@@ -6703,6 +6795,33 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-map": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
|
||||
@@ -6716,6 +6835,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pacote": {
|
||||
"version": "21.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz",
|
||||
@@ -6824,6 +6952,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -6921,6 +7058,15 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
@@ -7033,6 +7179,154 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
@@ -7093,6 +7387,15 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -7103,6 +7406,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||
@@ -7325,6 +7634,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -8690,6 +9005,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"express": "^5.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"qrcode": "1.5.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -31,6 +33,7 @@
|
||||
"@angular/cli": "^22.0.4",
|
||||
"@angular/compiler-cli": "^22.0.0",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^20.17.19",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
|
||||
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 |
@@ -1,17 +1,40 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { ApplicationConfig, provideAppInitializer, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { HttpRequest, provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
|
||||
import {
|
||||
ApplicationConfig,
|
||||
provideAppInitializer,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
} from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideClientHydration } from '@angular/platform-browser';
|
||||
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { authBootstrap } from './core/services/auth/auth-bootstrap';
|
||||
import { authInterceptor } from './core/services/auth/auth.interceptor';
|
||||
import { 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 = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideClientHydration(),
|
||||
provideHttpClient(),
|
||||
provideAppInitializer(tenantBootstrap)
|
||||
]
|
||||
provideClientHydration(
|
||||
withHttpTransferCacheOptions({
|
||||
includeRequestsWithAuthHeaders: true,
|
||||
filter: isStoreCatalogRequest,
|
||||
}),
|
||||
),
|
||||
provideHttpClient(
|
||||
withFetch(),
|
||||
withInterceptors([authInterceptor, globalLoadingInterceptor])
|
||||
),
|
||||
provideAppInitializer(authBootstrap),
|
||||
provideAppInitializer(tenantBootstrap),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1 +1,20 @@
|
||||
@if (status() === 'ready') {
|
||||
<router-outlet />
|
||||
<app-toast-container />
|
||||
<app-modal-host />
|
||||
<app-global-loading />
|
||||
} @else if (status() === 'not-found') {
|
||||
<section class="tenant-status">
|
||||
<div class="tenant-status__card">
|
||||
<span class="tenant-status__code">404</span>
|
||||
<h1 class="tenant-status__title">No encontramos una tienda para este dominio</h1>
|
||||
<p class="tenant-status__description">
|
||||
Verifica la configuracion del tenant o intenta nuevamente con un dominio valido.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="tenant-status tenant-status--loading" aria-hidden="true">
|
||||
<div class="tenant-status__spinner"></div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@ import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||
export const serverRoutes: ServerRoute[] = [
|
||||
{
|
||||
path: '**',
|
||||
renderMode: RenderMode.Prerender
|
||||
renderMode: RenderMode.Server
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,15 +1,104 @@
|
||||
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';
|
||||
|
||||
async function renderAppAt(url: string) {
|
||||
const tenant: Tenant = {
|
||||
id: 1,
|
||||
codigo: 'test',
|
||||
nombre: 'Test Tenant',
|
||||
dominio: 'localhost',
|
||||
primary_color: '#6376F3',
|
||||
secondary_color: '#A0A0A0',
|
||||
danger_color: '#FF8888',
|
||||
success_color: '#198754',
|
||||
header_bg_color: '#313131',
|
||||
footer_bg_color: '#313131',
|
||||
header_logo: 'https://example.com/header.png',
|
||||
footer_logo: 'https://example.com/footer.png',
|
||||
categories: []
|
||||
};
|
||||
|
||||
function createTenantServiceStub(
|
||||
status: 'ready' | 'not-found' = 'ready',
|
||||
currentTenant: Tenant | null = tenant
|
||||
) {
|
||||
const tenantState = signal(currentTenant);
|
||||
const statusState = signal(status);
|
||||
|
||||
return {
|
||||
tenant: tenantState.asReadonly(),
|
||||
status: statusState.asReadonly(),
|
||||
getTenant: () => tenantState(),
|
||||
getTenantApiUrl: () => {
|
||||
const tenantVal = tenantState();
|
||||
if (!tenantVal) {
|
||||
throw new Error('No se pudo resolver el tenant activo.');
|
||||
}
|
||||
return `http://localhost:8000/api/tenants/${tenantVal.codigo}`;
|
||||
},
|
||||
bootstrap: vi.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function createAuthServiceStub(isAuthenticated = false) {
|
||||
const tokenState = signal<string | null>(isAuthenticated ? 'test-token' : null);
|
||||
const userState = signal(
|
||||
isAuthenticated ? { id: 1, nombre_apellido: 'Ada Lovelace', email: 'ada@example.com' } : null
|
||||
);
|
||||
|
||||
return {
|
||||
user: userState.asReadonly(),
|
||||
token: tokenState.asReadonly(),
|
||||
isAuthenticated: computed(() => tokenState() !== null),
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
loadCurrentUser: vi.fn(),
|
||||
hydrateSession: vi.fn(),
|
||||
bootstrap: vi.fn().mockResolvedValue(undefined),
|
||||
clearSession: vi.fn(() => {
|
||||
userState.set(null);
|
||||
tokenState.set(null);
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function renderAppAt(
|
||||
url: string,
|
||||
tenantService: ReturnType<typeof createTenantServiceStub> = createTenantServiceStub(),
|
||||
authService: ReturnType<typeof createAuthServiceStub> = createAuthServiceStub()
|
||||
) {
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideRouter(routes)]
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
{
|
||||
provide: TenantService,
|
||||
useValue: tenantService
|
||||
},
|
||||
{
|
||||
provide: CartService,
|
||||
useValue: {
|
||||
cart: signal(null).asReadonly(),
|
||||
loadCart: () => of({ id: 1, items: [], subtotal: '0' })
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: authService
|
||||
}
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
const router = TestBed.inject(Router);
|
||||
@@ -24,21 +113,135 @@ async function renderAppAt(url: string) {
|
||||
}
|
||||
|
||||
describe('app routes', () => {
|
||||
it('loads the store layout at /store', async () => {
|
||||
const { fixture, router } = await renderAppAt('/store');
|
||||
it('loads the store layout at /', async () => {
|
||||
const { fixture, router } = await renderAppAt('/');
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(router.url).toBe('/store');
|
||||
expect(router.url).toBe('/');
|
||||
expect(compiled.querySelector('.store-layout')).not.toBeNull();
|
||||
expect(compiled.querySelector('.store-home')).not.toBeNull();
|
||||
expect(compiled.textContent).toContain('Tienda en construccion');
|
||||
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('keeps the root redirect under componentes-test', async () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,11 +8,11 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'store',
|
||||
loadChildren: () => import('./features/store/store.routes').then((m) => m.routes)
|
||||
pathMatch: 'full',
|
||||
redirectTo: ''
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'componentes-test'
|
||||
loadChildren: () => import('./features/store/store.routes').then((m) => m.routes)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,3 +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 { 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', () => {
|
||||
beforeEach(async () => {
|
||||
beforeAll(() => {
|
||||
try {
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting()
|
||||
);
|
||||
} catch {
|
||||
// Test environment may already be initialized by another setup entrypoint.
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
it('creates the app when the tenant is ready', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: TenantService,
|
||||
useValue: createTenantServiceStub('ready', tenant)
|
||||
}
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary')).toBe(
|
||||
tenant.primary_color
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary')).toBe(
|
||||
tenant.secondary_color
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger')).toBe(
|
||||
tenant.danger_color
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary-rgb')).toBe(
|
||||
'99, 118, 243'
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary-rgb')).toBe(
|
||||
'160, 160, 160'
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe(
|
||||
'255, 136, 136'
|
||||
);
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
it('renders the tenant not found screen when the tenant is missing', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: TenantService,
|
||||
useValue: createTenantServiceStub('not-found', null)
|
||||
}
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
it('should render title', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('router-outlet')).not.toBeNull();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('No encontramos una tienda para este dominio');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,97 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
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({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
ToastContainerComponent,
|
||||
ModalHostComponent,
|
||||
GlobalLoadingComponent
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
styleUrl: './app.scss',
|
||||
host: {
|
||||
'[style.--tenant-primary]': 'tenantPrimaryColor()',
|
||||
'[style.--tenant-secondary]': 'tenantSecondaryColor()',
|
||||
'[style.--tenant-danger]': 'tenantDangerColor()',
|
||||
'[style.--tenant-success]': 'tenantSuccessColor()',
|
||||
'[style.--color-primary]': 'tenantPrimaryColor()',
|
||||
'[style.--color-secondary]': 'tenantSecondaryColor()',
|
||||
'[style.--color-danger]': 'tenantDangerColor()',
|
||||
'[style.--color-success]': 'tenantSuccessColor()',
|
||||
'[style.--tenant-primary-rgb]': 'tenantPrimaryColorRgb()',
|
||||
'[style.--tenant-secondary-rgb]': 'tenantSecondaryColorRgb()',
|
||||
'[style.--tenant-danger-rgb]': 'tenantDangerColorRgb()',
|
||||
'[style.--tenant-success-rgb]': 'tenantSuccessColorRgb()',
|
||||
'[style.--color-primary-rgb]': 'tenantPrimaryColorRgb()',
|
||||
'[style.--color-secondary-rgb]': 'tenantSecondaryColorRgb()',
|
||||
'[style.--color-danger-rgb]': 'tenantDangerColorRgb()',
|
||||
'[style.--color-success-rgb]': 'tenantSuccessColorRgb()',
|
||||
'[style.--tenant-header-bg]': 'tenantHeaderBgColor()',
|
||||
'[style.--tenant-footer-bg]': 'tenantFooterBgColor()',
|
||||
'[style.--color-header-bg]': 'tenantHeaderBgColor()',
|
||||
'[style.--color-footer-bg]': 'tenantFooterBgColor()'
|
||||
}
|
||||
})
|
||||
export class App {}
|
||||
export class App {
|
||||
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 {}
|
||||
@@ -1,58 +1,74 @@
|
||||
<footer class="store-layout__footer mt-auto text-light">
|
||||
<div class="container-xl px-3 px-md-4 py-4 py-md-5">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start">
|
||||
<div class="col-12">
|
||||
<div
|
||||
class="store-layout__brand-slot d-inline-flex align-items-center gap-3"
|
||||
data-testid="store-footer-brand-slot"
|
||||
aria-label="Logo del comercio"
|
||||
>
|
||||
@if (logoUrl && logoLoaded) {
|
||||
@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>
|
||||
|
||||
@if (logoUrl && !logoLoaded) {
|
||||
<img class="store-layout__brand-logo store-layout__brand-logo--preload" [src]="logoUrl" alt="" (load)="onLogoLoad()" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start">
|
||||
<div class="d-grid gap-2">
|
||||
<div class="d-flex align-items-center gap-2 text-light-emphasis">
|
||||
<div class="d-flex align-items-center gap-2 ">
|
||||
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>
|
||||
<span>Av. San Lorenzo 1542, Rosario</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 text-light-emphasis">
|
||||
<div class="d-flex align-items-center gap-2 ">
|
||||
<i class="fa-solid fa-phone" aria-hidden="true"></i>
|
||||
<span>54 9 (0341) 6658247</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-0 small text-white-50">Copyright © {{ currentYear }} - Sonder</p>
|
||||
<p class="mb-0 small store-layout__muted-text">Copyright © {{ currentYear }} - {{ storeName || 'Sonder' }}</p>
|
||||
</div>
|
||||
|
||||
@for (section of footerSections; track section.heading) {
|
||||
<div class="col-12 col-md-6 col-xl-2 d-grid gap-3 align-content-start">
|
||||
<h2 class="store-layout__footer-heading">{{ section.heading }}</h2>
|
||||
<div class="col-12 col-md-6 col-xl-2 d-grid align-content-start">
|
||||
<div class="d-grid gap-2">
|
||||
@for (link of section.links; track link) {
|
||||
<span class="text-light-emphasis">{{ link }}</span>
|
||||
@for (link of section.links; track link.label) {
|
||||
@if (link.routerLink) {
|
||||
<a
|
||||
class="store-layout__footer-link"
|
||||
[routerLink]="link.routerLink"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
} @else if (link.action === 'logout') {
|
||||
<button
|
||||
type="button"
|
||||
class="store-layout__footer-link store-layout__footer-action"
|
||||
(click)="logoutClick.emit()"
|
||||
>
|
||||
{{ link.label }}
|
||||
</button>
|
||||
} @else {
|
||||
<span class="store-layout__muted-text">{{ link.label }}</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="col-12 col-md-6 col-xl-3 d-grid gap-3 align-content-start">
|
||||
<h2 class="store-layout__footer-heading">Seguinos:</h2>
|
||||
<div class="col-12 col-md-6 col-xl-3 d-grid align-content-start">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
@for (social of socialLinks; track social.label) {
|
||||
<span class="fs-5 text-white">
|
||||
<i [class]="social.iconClass" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{ social.label }}</span>
|
||||
</span>
|
||||
@for (social of socialMedia; track social.code) {
|
||||
<app-social-media
|
||||
class="text-white"
|
||||
[code]="social.code"
|
||||
[icon]="social.icon"
|
||||
[name]="social.name"
|
||||
[url]="social.url"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
.store-layout__footer {
|
||||
background-color: #323232;
|
||||
background-color: var(--tenant-footer-bg);
|
||||
}
|
||||
|
||||
.store-layout__brand-slot {
|
||||
width: min(100%, 12rem);
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
@@ -18,13 +19,7 @@
|
||||
height: 100%;
|
||||
max-height: 2.75rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.store-layout__brand-logo--preload {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
object-position: left center;
|
||||
}
|
||||
|
||||
.store-layout__brand-logo-skeleton {
|
||||
@@ -62,11 +57,28 @@
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.store-layout__footer-heading {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #f8f8f8;
|
||||
.store-layout__muted-text {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.store-layout__footer-link {
|
||||
width: fit-content;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: #ffffff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.store-layout__footer-action {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@keyframes store-footer-logo-skeleton {
|
||||
|
||||
@@ -1,41 +1,30 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Component, Input, output } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { SocialMedia } from '../../../services/tenant.interface';
|
||||
import { SocialMediaComponent } from '../../../../shared/components/social-media/social-media.component';
|
||||
|
||||
export type StoreFooterLink = {
|
||||
label: string;
|
||||
routerLink?: string;
|
||||
action?: 'logout';
|
||||
};
|
||||
|
||||
export type StoreFooterSection = {
|
||||
heading: string;
|
||||
links: string[];
|
||||
};
|
||||
|
||||
export type StoreSocialLink = {
|
||||
label: string;
|
||||
iconClass: string;
|
||||
links: StoreFooterLink[];
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-store-footer',
|
||||
imports: [],
|
||||
imports: [RouterLink, SocialMediaComponent],
|
||||
templateUrl: './store-footer.component.html',
|
||||
styleUrl: './store-footer.component.scss'
|
||||
styleUrl: './store-footer.component.scss',
|
||||
})
|
||||
export class StoreFooterComponent {
|
||||
private logoUrlValue: string | null = null;
|
||||
|
||||
@Input({ required: true }) currentYear = new Date().getFullYear();
|
||||
@Input({ required: true }) footerSections: StoreFooterSection[] = [];
|
||||
@Input({ required: true }) socialLinks: StoreSocialLink[] = [];
|
||||
|
||||
@Input()
|
||||
set logoUrl(value: string | null) {
|
||||
this.logoUrlValue = value;
|
||||
this.logoLoaded = false;
|
||||
}
|
||||
|
||||
get logoUrl(): string | null {
|
||||
return this.logoUrlValue;
|
||||
}
|
||||
|
||||
protected logoLoaded = false;
|
||||
|
||||
protected onLogoLoad(): void {
|
||||
this.logoLoaded = true;
|
||||
}
|
||||
@Input({ required: true }) socialMedia: SocialMedia[] = [];
|
||||
@Input() logoUrl: string | null = null;
|
||||
@Input() storeName: 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);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,126 @@
|
||||
<header class="store-layout__header bg-white border-bottom">
|
||||
<div class="border-bottom">
|
||||
<div class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<header class="store-layout__header">
|
||||
<div class="store-layout__header-main">
|
||||
<div
|
||||
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0"
|
||||
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 && logoLoaded) {
|
||||
<img class="store-layout__brand-logo" [src]="logoUrl" alt="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>
|
||||
|
||||
@if (logoUrl && !logoLoaded) {
|
||||
<img class="store-layout__brand-logo store-layout__brand-logo--preload" [src]="logoUrl" alt="" (load)="onLogoLoad()" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="d-flex flex-column flex-md-row align-items-stretch align-items-md-center gap-3 ms-auto w-100 w-lg-auto">
|
||||
<div
|
||||
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 border-dark border-end-0 rounded-start"
|
||||
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 btn-outline-dark border-start-0 rounded-end px-3" aria-label="Buscar">
|
||||
<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>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-end gap-1" aria-label="Acciones de usuario">
|
||||
@for (action of headerActions; track action.label) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link text-dark p-2 text-decoration-none"
|
||||
[attr.aria-label]="action.label"
|
||||
[attr.title]="action.label"
|
||||
@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()"
|
||||
>
|
||||
<i [class]="action.iconClass" aria-hidden="true"></i>
|
||||
</button>
|
||||
{{ 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>
|
||||
<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-dark text-uppercase fw-bold text-decoration-none store-layout__category-trigger"
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
.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: 10rem;
|
||||
min-width: 150px;
|
||||
max-width:40px;
|
||||
}
|
||||
|
||||
.store-layout__brand-logo-shell {
|
||||
@@ -15,31 +26,102 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.store-layout__brand-logo--preload {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.store-layout__brand-logo-skeleton {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0.75rem;
|
||||
background: linear-gradient(90deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.14) 50%, rgba(0, 0, 0, 0.08) 100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.12) 0%,
|
||||
rgba(255, 255, 255, 0.22) 50%,
|
||||
rgba(255, 255, 255, 0.12) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
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: min(100%, 24rem);
|
||||
min-width: 18rem;
|
||||
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 {
|
||||
@@ -56,8 +138,4 @@
|
||||
.store-layout__brand-slot {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.store-layout__search {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,114 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
export type StoreHeaderAction = {
|
||||
label: string;
|
||||
iconClass: string;
|
||||
};
|
||||
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: [],
|
||||
imports: [
|
||||
ButtonComponent,
|
||||
CartIconComponent,
|
||||
IconButtonComponent,
|
||||
RouterLink,
|
||||
ReactiveFormsModule,
|
||||
UserDropdownComponent,
|
||||
CategoryDropdownComponent,
|
||||
],
|
||||
templateUrl: './store-header.component.html',
|
||||
styleUrl: './store-header.component.scss'
|
||||
styleUrl: './store-header.component.scss',
|
||||
})
|
||||
export class StoreHeaderComponent {
|
||||
private logoUrlValue: string | null = null;
|
||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||
|
||||
@Input({ required: true }) headerActions: StoreHeaderAction[] = [];
|
||||
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>();
|
||||
|
||||
@Input()
|
||||
set logoUrl(value: string | null) {
|
||||
this.logoUrlValue = value;
|
||||
this.logoLoaded = false;
|
||||
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;
|
||||
}
|
||||
|
||||
get logoUrl(): string | null {
|
||||
return this.logoUrlValue;
|
||||
const target = event.target;
|
||||
if (target instanceof Node && !this.elementRef.nativeElement.contains(target)) {
|
||||
this.isUserDropdownOpen.set(false);
|
||||
this.isCategoryDropdownOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected logoLoaded = false;
|
||||
@HostListener('document:keydown.escape')
|
||||
protected onEscapeKey(): void {
|
||||
this.isUserDropdownOpen.set(false);
|
||||
this.isCategoryDropdownOpen.set(false);
|
||||
}
|
||||
|
||||
protected onLogoLoad(): void {
|
||||
this.logoLoaded = true;
|
||||
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');
|
||||
}
|
||||
@@ -1,14 +1,67 @@
|
||||
<div class="store-layout d-flex flex-column flex-grow-1">
|
||||
<app-store-header [headerActions]="headerActions" [logoUrl]="tenant()?.header_logo ?? null" />
|
||||
<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"
|
||||
[socialLinks]="socialLinks"
|
||||
[footerSections]="footerSections()"
|
||||
[socialMedia]="tenant()?.social_media ?? []"
|
||||
[logoUrl]="tenant()?.footer_logo ?? null"
|
||||
[storeName]="tenant()?.nombre ?? null"
|
||||
(logoutClick)="onLogoutClick()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,55 @@
|
||||
:host {
|
||||
display: flex;
|
||||
min-height: 100dvh;
|
||||
background-color: #f6f6f6;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,194 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { Tenant } from '../../services/tenant.interface';
|
||||
import { TenantService } from '../../services/tenant.service';
|
||||
import { CartService } from '../../services/cart/cart.service';
|
||||
import { Cart } from '../../services/cart/cart.interface';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { AuthService } from '../../services/auth/auth.service';
|
||||
import { AuthUser } from '../../services/auth/auth.interfaces';
|
||||
import { StoreLayoutComponent } from './store-layout.component';
|
||||
import { StoreHeaderComponent } from './store-header/store-header.component';
|
||||
import { CheckoutService } from '../../services/checkout.service';
|
||||
|
||||
const tenant: Tenant = {
|
||||
id: 1,
|
||||
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([])]
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -23,7 +204,7 @@ describe('StoreLayoutComponent', () => {
|
||||
expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the placeholder branding and action icons', () => {
|
||||
it('renders the tenant branding and action icons', () => {
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -31,6 +212,16 @@ describe('StoreLayoutComponent', () => {
|
||||
|
||||
expect(compiled.querySelector('[data-testid="store-header-brand-slot"]')).not.toBeNull();
|
||||
expect(compiled.querySelector('[data-testid="store-footer-brand-slot"]')).not.toBeNull();
|
||||
expect(
|
||||
compiled.querySelector(
|
||||
`app-store-header img.store-layout__brand-logo[src="${tenant.header_logo}"]`,
|
||||
),
|
||||
).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();
|
||||
@@ -38,4 +229,383 @@ describe('StoreLayoutComponent', () => {
|
||||
expect(compiled.querySelector('.fa-facebook')).not.toBeNull();
|
||||
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('navigates to search results when the search button is clicked', () => {
|
||||
const router = TestBed.inject(Router);
|
||||
vi.spyOn(router, 'navigate');
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
||||
const searchButton = header.query(By.css('.store-layout__search-button'));
|
||||
|
||||
(header.componentInstance as any).searchControl.setValue(' zapatillas ');
|
||||
searchButton.triggerEventHandler('click');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/buscar'], {
|
||||
queryParams: { q: 'zapatillas', page: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents the native submit and searches when Enter is pressed', () => {
|
||||
const router = TestBed.inject(Router);
|
||||
vi.spyOn(router, 'navigate');
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
||||
const input = header.query(By.css('#store-search-input'));
|
||||
const keyboardEvent = {
|
||||
preventDefault: vi.fn(),
|
||||
};
|
||||
|
||||
(header.componentInstance as any).searchControl.setValue('remera');
|
||||
input.triggerEventHandler('keydown.enter', keyboardEvent);
|
||||
|
||||
expect(keyboardEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/buscar'], {
|
||||
queryParams: { q: 'remera', page: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('requires at least three characters to search', () => {
|
||||
const router = TestBed.inject(Router);
|
||||
vi.spyOn(router, 'navigate');
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
||||
const input = header.query(By.css('#store-search-input'));
|
||||
const searchButton = header.query(By.css('.store-layout__search-button'));
|
||||
|
||||
expect(input.nativeElement.getAttribute('minlength')).toBe('3');
|
||||
|
||||
(header.componentInstance as any).searchControl.setValue('ab');
|
||||
searchButton.triggerEventHandler('click');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain(
|
||||
'Ingresá al menos 3 caracteres para buscar.',
|
||||
);
|
||||
expect(input.nativeElement.getAttribute('aria-invalid')).toBe('true');
|
||||
});
|
||||
|
||||
it('renders the primary tickets action when the tenant has the tickets menu', () => {
|
||||
const router = TestBed.inject(Router);
|
||||
vi.spyOn(router, 'navigate');
|
||||
const configuredTicketsLabel = 'Entradas digitales';
|
||||
tenantState.set({
|
||||
...tenant,
|
||||
menues: tenant.menues?.map((menu) =>
|
||||
menu.code === 'account'
|
||||
? {
|
||||
...menu,
|
||||
submenues: menu.submenues.map((submenu) =>
|
||||
submenu.code === 'account.tickets'
|
||||
? { ...submenu, label: configuredTicketsLabel }
|
||||
: submenu,
|
||||
),
|
||||
}
|
||||
: menu,
|
||||
),
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const ticketsAction = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>(
|
||||
'app-button[data-testid="store-header-tickets"]',
|
||||
);
|
||||
const ticketsButton = ticketsAction?.querySelector('button');
|
||||
|
||||
expect(ticketsAction).not.toBeNull();
|
||||
expect(ticketsButton).not.toBeNull();
|
||||
expect(ticketsButton?.textContent?.trim()).toBe(configuredTicketsLabel);
|
||||
expect(ticketsButton?.classList).toContain('btn-primary');
|
||||
|
||||
ticketsButton?.click();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/mi-cuenta/tickets']);
|
||||
});
|
||||
|
||||
it('does not render the tickets action when the tenant lacks the tickets menu', () => {
|
||||
tenantState.set({
|
||||
...tenant,
|
||||
menues: tenant.menues?.map((menu) =>
|
||||
menu.code === 'account'
|
||||
? {
|
||||
...menu,
|
||||
submenues: menu.submenues.filter((submenu) => submenu.code !== 'account.tickets'),
|
||||
}
|
||||
: menu,
|
||||
),
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector('[data-testid="store-header-tickets"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('redirects to /login when user icon is clicked and user is not logged in', () => {
|
||||
const authService = TestBed.inject(AuthService);
|
||||
const router = TestBed.inject(Router);
|
||||
vi.spyOn(router, 'navigate');
|
||||
|
||||
(authService.isAuthenticated as any).set(false);
|
||||
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const userButton = compiled.querySelector(
|
||||
'app-icon-button[variant="user"] button',
|
||||
) as HTMLButtonElement;
|
||||
expect(userButton).not.toBeNull();
|
||||
userButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(compiled.querySelector('app-user-dropdown')).toBeNull();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
|
||||
it('opens authenticated user dropdown without redirecting on icon click', () => {
|
||||
const authService = TestBed.inject(AuthService);
|
||||
const router = TestBed.inject(Router);
|
||||
vi.spyOn(router, 'navigate');
|
||||
|
||||
(authService.isAuthenticated as any).set(true);
|
||||
(authService.user as any).set({
|
||||
id: 1,
|
||||
nombre_apellido: 'Ada Lovelace',
|
||||
email: 'ada@example.com',
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const userButton = compiled.querySelector(
|
||||
'app-icon-button[variant="user"] button',
|
||||
) as HTMLButtonElement;
|
||||
expect(userButton).not.toBeNull();
|
||||
userButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(compiled.querySelector('app-user-dropdown')).not.toBeNull();
|
||||
expect(compiled.textContent).toContain('Ada Lovelace');
|
||||
expect(compiled.querySelector('[data-testid="user-dropdown-account"]')).not.toBeNull();
|
||||
expect(
|
||||
compiled.querySelector('[data-testid="user-dropdown-account.purchases"]'),
|
||||
).not.toBeNull();
|
||||
expect(compiled.querySelector('[data-testid="user-dropdown-account.tickets"]')).toBeNull();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs out from the authenticated user dropdown, clears the cart and returns home', () => {
|
||||
const authService = TestBed.inject(AuthService);
|
||||
const cartService = TestBed.inject(CartService);
|
||||
const router = TestBed.inject(Router);
|
||||
vi.spyOn(router, 'navigate');
|
||||
|
||||
(authService.isAuthenticated as any).set(true);
|
||||
(authService.user as any).set({
|
||||
id: 1,
|
||||
nombre_apellido: 'Ada Lovelace',
|
||||
email: 'ada@example.com',
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const userButton = compiled.querySelector(
|
||||
'app-icon-button[variant="user"] button',
|
||||
) as HTMLButtonElement;
|
||||
userButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const logoutButton = compiled.querySelector(
|
||||
'[data-testid="user-dropdown-logout"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(logoutButton).not.toBeNull();
|
||||
logoutButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(authService.logout).toHaveBeenCalled();
|
||||
expect(cartService.clearCart).toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||
});
|
||||
|
||||
it('provides account actions from the footer', () => {
|
||||
const authService = TestBed.inject(AuthService);
|
||||
const cartService = TestBed.inject(CartService);
|
||||
const router = TestBed.inject(Router);
|
||||
vi.spyOn(router, 'navigate');
|
||||
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const footerLinks = Array.from(
|
||||
compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'),
|
||||
);
|
||||
const profileLink = footerLinks.find((link) => link.textContent?.trim() === 'Mi cuenta');
|
||||
const purchasesLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis compras');
|
||||
const ticketsLink = footerLinks.find((link) => link.textContent?.trim() === 'Mis tickets');
|
||||
const logoutButton = Array.from(
|
||||
compiled.querySelectorAll<HTMLButtonElement>('app-store-footer button'),
|
||||
).find((button) => button.textContent?.trim() === 'Cerrar sesion');
|
||||
|
||||
expect(profileLink?.getAttribute('href')).toBe('/mi-cuenta/datos-personales');
|
||||
expect(purchasesLink?.getAttribute('href')).toBe('/mi-cuenta/compras');
|
||||
expect(ticketsLink).toBeUndefined();
|
||||
expect(logoutButton).toBeDefined();
|
||||
|
||||
logoutButton!.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(authService.logout).toHaveBeenCalled();
|
||||
expect(cartService.clearCart).toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||
});
|
||||
|
||||
it('leaves checkout before logging out so the pending purchase can be cancelled', async () => {
|
||||
const authService = TestBed.inject(AuthService);
|
||||
const cartService = TestBed.inject(CartService);
|
||||
const router = TestBed.inject(Router);
|
||||
vi.spyOn(router, 'url', 'get').mockReturnValue('/checkout?purchase=25');
|
||||
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
await (fixture.componentInstance as any).onLogoutClick();
|
||||
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/']);
|
||||
expect(authService.logout).toHaveBeenCalled();
|
||||
expect(navigateSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
(authService.logout as any).mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(cartService.clearCart).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders only the help submenus assigned to the tenant in the footer', () => {
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const footerLinks = Array.from(
|
||||
compiled.querySelectorAll<HTMLAnchorElement>('app-store-footer a.store-layout__footer-link'),
|
||||
);
|
||||
const helpLinks = footerLinks.filter((link) =>
|
||||
link.getAttribute('href')?.startsWith('/ayuda/'),
|
||||
);
|
||||
|
||||
expect(helpLinks.map((link) => link.textContent?.trim())).toEqual([
|
||||
'Contacto',
|
||||
'Preguntas frecuentes',
|
||||
]);
|
||||
expect(helpLinks.map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/ayuda/contacto',
|
||||
'/ayuda/preguntas-frecuentes',
|
||||
]);
|
||||
expect(compiled.textContent).not.toContain('Medios de pago');
|
||||
});
|
||||
|
||||
it('creates a purchase and redirects to checkout when cart buy is clicked', async () => {
|
||||
const router = TestBed.inject(Router);
|
||||
vi.spyOn(router, 'navigate');
|
||||
cartState.set({
|
||||
id: 1,
|
||||
tenant_codigo: tenant.codigo,
|
||||
status: 'active',
|
||||
subtotal: '100.00',
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
cantidad: 1,
|
||||
precio_unitario: '100.00',
|
||||
catalog_item_id: 1,
|
||||
variant_id: null,
|
||||
product: {
|
||||
nombre: 'Producto',
|
||||
imagen: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
authUserState.set({
|
||||
id: 7,
|
||||
nombre_apellido: 'Juan Perez',
|
||||
email: 'juan@example.com',
|
||||
dni: '12345678',
|
||||
telefono: '3415555555',
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
(fixture.componentInstance as any).isCartOpen.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const buyButton = Array.from(compiled.querySelectorAll('app-button button')).find(
|
||||
(button) => button.textContent?.trim() === 'Comprar',
|
||||
) as HTMLButtonElement | undefined;
|
||||
|
||||
expect(buyButton).toBeDefined();
|
||||
buyButton!.click();
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(checkoutServiceStub.startCheckout).toHaveBeenCalledWith('test', {
|
||||
cart_id: 1,
|
||||
});
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/checkout'], {
|
||||
queryParams: { purchase: 55 },
|
||||
});
|
||||
expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('allows modifying quantities directly in the regular cart without a toggle', () => {
|
||||
cartState.set({
|
||||
id: 1,
|
||||
tenant_codigo: tenant.codigo,
|
||||
status: 'active',
|
||||
subtotal: '100.00',
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
cantidad: 1,
|
||||
precio_unitario: '100.00',
|
||||
catalog_item_id: 1,
|
||||
variant_id: null,
|
||||
product: {
|
||||
nombre: 'Producto',
|
||||
imagen: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
(fixture.componentInstance as any).isCartOpen.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const cartItem = fixture.debugElement.query(By.css('app-cart-item'));
|
||||
const buyButton = Array.from(
|
||||
element.querySelectorAll<HTMLButtonElement>('app-button button'),
|
||||
).find((button) => button.textContent?.trim() === 'Comprar');
|
||||
|
||||
expect(element.querySelector('.cart-edit-btn')).toBeNull();
|
||||
expect(cartItem.componentInstance.quantityDisabled()).toBe(false);
|
||||
expect(buyButton).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,59 +1,231 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||
import { Router, RouterOutlet } from '@angular/router';
|
||||
import { TenantService } from '../../services/tenant.service';
|
||||
import { StoreFooterComponent, StoreFooterSection, StoreSocialLink } from './store-footer/store-footer.component';
|
||||
import { StoreHeaderComponent, StoreHeaderAction } from './store-header/store-header.component';
|
||||
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],
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
StoreHeaderComponent,
|
||||
StoreFooterComponent,
|
||||
CartComponent,
|
||||
ButtonComponent,
|
||||
],
|
||||
templateUrl: './store-layout.component.html',
|
||||
styleUrl: './store-layout.component.scss'
|
||||
styleUrl: './store-layout.component.scss',
|
||||
})
|
||||
export class StoreLayoutComponent {
|
||||
export class StoreLayoutComponent implements OnInit {
|
||||
private readonly tenantService = inject(TenantService);
|
||||
private readonly 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');
|
||||
|
||||
protected readonly headerActions: StoreHeaderAction[] = [
|
||||
{
|
||||
label: 'Carrito',
|
||||
iconClass: 'fa-solid fa-cart-shopping'
|
||||
},
|
||||
{
|
||||
label: 'Mi cuenta',
|
||||
iconClass: 'fa-solid fa-circle-user'
|
||||
if (!accountMenu) {
|
||||
return [];
|
||||
}
|
||||
];
|
||||
|
||||
protected readonly footerSections: StoreFooterSection[] = [
|
||||
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: ['Mi cuenta', 'Mis compras', 'Cerrar sesion']
|
||||
links: [
|
||||
...this.accountNavigationMenus().map((menu) => ({
|
||||
label: menu.label,
|
||||
routerLink: menu.route,
|
||||
})),
|
||||
{ label: 'Cerrar sesion', action: 'logout' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Ayuda',
|
||||
links: ['Contacto', 'Ayuda', 'Preguntas frecuentes']
|
||||
}
|
||||
];
|
||||
|
||||
protected readonly socialLinks: StoreSocialLink[] = [
|
||||
{
|
||||
label: 'Instagram',
|
||||
iconClass: 'fa-brands fa-instagram'
|
||||
},
|
||||
{
|
||||
label: 'WhatsApp',
|
||||
iconClass: 'fa-brands fa-whatsapp'
|
||||
},
|
||||
{
|
||||
label: 'Facebook',
|
||||
iconClass: 'fa-brands fa-facebook'
|
||||
},
|
||||
{
|
||||
label: 'LinkedIn',
|
||||
iconClass: 'fa-brands fa-linkedin-in'
|
||||
}
|
||||
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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
113
src/app/core/services/catalog/catalog.interface.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ApiPaginatedResponse } from '../api-paginated-response.interface';
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
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;
|
||||
stock_tecnico: number | null;
|
||||
minimum_use_date?: string | null;
|
||||
maximum_use_date?: string | null;
|
||||
effective_minimum_use_date?: string | null;
|
||||
effective_maximum_use_date?: string | null;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
|
||||
images: string[];
|
||||
}
|
||||
|
||||
export interface CatalogItemDetail {
|
||||
id: number;
|
||||
category_id: number | null;
|
||||
brand_id: number | null;
|
||||
slug: string;
|
||||
nombre: string;
|
||||
descripcion: string | null;
|
||||
precio: string;
|
||||
category: string | null;
|
||||
brand: string | null;
|
||||
inventory_policy: InventoryPolicy;
|
||||
has_tickets: boolean;
|
||||
minimum_use_date: string | null;
|
||||
maximum_use_date: string | null;
|
||||
attributes: ProductAttribute[];
|
||||
variants: CatalogItemVariant[];
|
||||
selected_variant?: SelectedCatalogItemVariant;
|
||||
stock_tecnico?: number | null;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export type CatalogProductLayout = 'row' | 'column_with_image' | 'column_with_cart';
|
||||
export type CatalogGroupLayout = 'paginated' | 'simple' | 'simple_vertical' | 'carousel';
|
||||
|
||||
export interface CatalogFeaturedItemVariant {
|
||||
id: number;
|
||||
stock_tecnico: number | null;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CatalogFeaturedItem {
|
||||
id: number;
|
||||
nombre: string;
|
||||
descripcion?: string | null;
|
||||
precio: number | string;
|
||||
image?: string | null;
|
||||
stock_tecnico?: number | null;
|
||||
variants?: CatalogFeaturedItemVariant[];
|
||||
}
|
||||
|
||||
export type CatalogFeaturedItems =
|
||||
| ApiPaginatedResponse<CatalogFeaturedItem[]>
|
||||
| CatalogFeaturedItem[];
|
||||
|
||||
export interface CatalogFeaturedGroup {
|
||||
id: number;
|
||||
title: string;
|
||||
layout: CatalogProductLayout;
|
||||
group_layout: CatalogGroupLayout;
|
||||
group_order: number;
|
||||
items: CatalogFeaturedItems;
|
||||
}
|
||||
|
||||
export interface CatalogCategory {
|
||||
id: number;
|
||||
nombre: string;
|
||||
categoria_id: number | null;
|
||||
}
|
||||
|
||||
export interface CategoryItemsResponse extends ApiPaginatedResponse<CatalogFeaturedItem[]> {
|
||||
category: CatalogCategory;
|
||||
layout: CatalogProductLayout;
|
||||
group_layout: CatalogGroupLayout;
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
290
src/app/core/services/checkout.service.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
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;
|
||||
created_at: string | null;
|
||||
total: string;
|
||||
}
|
||||
|
||||
export interface PurchaseDetailAttributeResponse {
|
||||
name: string;
|
||||
value: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export interface PurchaseDetailItemResponse {
|
||||
id: number;
|
||||
quantity: number;
|
||||
unit_price: string;
|
||||
line_total: string;
|
||||
source_catalog_item_id: number | null;
|
||||
source_variant_id: number | null;
|
||||
item_details: {
|
||||
nombre: string;
|
||||
descripcion: string | null;
|
||||
slug: string;
|
||||
imagen: string | null;
|
||||
attributes: PurchaseDetailAttributeResponse[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface PurchaseDetailResponse extends PurchaseStatusResponse {
|
||||
id: number;
|
||||
cart_id: number | null;
|
||||
tenant_codigo: string;
|
||||
user_id: number;
|
||||
created_at: string | null;
|
||||
payment_method: string | null;
|
||||
dni: string | null;
|
||||
transfer_payer_dni: string | null;
|
||||
telefono: string | null;
|
||||
nombre_apellido: string | null;
|
||||
email: string | null;
|
||||
items_source: 'purchase';
|
||||
items: PurchaseDetailItemResponse[];
|
||||
tickets_count?: number;
|
||||
has_generated_tickets?: boolean;
|
||||
subtotal: string;
|
||||
total: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class CheckoutService 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,7 @@ import { TenantService } from './tenant.service';
|
||||
|
||||
describe('tenantBootstrap', () => {
|
||||
it('waits for TenantService.bootstrap to resolve', async () => {
|
||||
const bootstrapSpy = vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
codigo: 'test',
|
||||
nombre: 'Test Tennant',
|
||||
dominio: 'localhost',
|
||||
primary_color: '#6376F3',
|
||||
secondary_color: '#A0A0A0',
|
||||
danger_color: '#FF8888',
|
||||
header_footer_bg_color: '#313131',
|
||||
header_logo: 'https://example.com/header.png',
|
||||
footer_logo: 'https://example.com/footer.png'
|
||||
});
|
||||
const bootstrapSpy = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
|
||||
@@ -5,5 +5,5 @@ import { TenantService } from './tenant.service';
|
||||
export function tenantBootstrap(): Promise<void> {
|
||||
const tenantService = inject(TenantService);
|
||||
|
||||
return tenantService.bootstrap().then(() => undefined);
|
||||
return tenantService.bootstrap();
|
||||
}
|
||||
|
||||
25
src/app/core/services/tenant-ssr-cache.store.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { makeStateKey } from '@angular/core';
|
||||
|
||||
import { Tenant } from './tenant.interface';
|
||||
|
||||
export type TenantBootstrapStatus = 'idle' | 'ready' | 'not-found';
|
||||
|
||||
export type TenantSsrState =
|
||||
| {
|
||||
status: 'ready';
|
||||
tenant: Tenant;
|
||||
}
|
||||
| {
|
||||
status: 'not-found';
|
||||
};
|
||||
|
||||
export const DEFAULT_TENANT_BRANDING = {
|
||||
primaryColor: '#102bda',
|
||||
secondaryColor: '#9e9e9e',
|
||||
dangerColor: '#fd4c4c',
|
||||
successColor: '#198754',
|
||||
headerBgColor: '#313131',
|
||||
footerBgColor: '#313131'
|
||||
} as const;
|
||||
|
||||
export const TENANT_SSR_STATE_KEY = makeStateKey<TenantSsrState | null>('tenant-ssr-state');
|
||||
@@ -1,3 +1,52 @@
|
||||
import { ApiResponse } from './api-response.interface';
|
||||
|
||||
export interface BankAccount {
|
||||
id: number;
|
||||
tenant_code: string;
|
||||
titular: string;
|
||||
entidad: string;
|
||||
alias: string;
|
||||
cvu: string;
|
||||
}
|
||||
|
||||
export interface HeroConfig {
|
||||
background_image?: string | null;
|
||||
title_html?: string | null;
|
||||
description_html?: string | null;
|
||||
button_text?: string | null;
|
||||
button_href?: string | null;
|
||||
}
|
||||
|
||||
export interface EventConfig {
|
||||
title?: string | null;
|
||||
location?: string | null;
|
||||
dates?: string[] | null;
|
||||
}
|
||||
|
||||
export interface Menu {
|
||||
id: number;
|
||||
code: string;
|
||||
label: string;
|
||||
parent_menu_code: string | null;
|
||||
content_type: 'static' | 'dynamic';
|
||||
route: string;
|
||||
static_content?: unknown;
|
||||
submenues: Menu[];
|
||||
}
|
||||
|
||||
export interface SocialMedia {
|
||||
code: string;
|
||||
icon: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
nombre: string;
|
||||
subcategories: Category[];
|
||||
}
|
||||
|
||||
export interface Tenant {
|
||||
id: number;
|
||||
codigo: string;
|
||||
@@ -6,11 +55,22 @@ export interface Tenant {
|
||||
primary_color: string;
|
||||
secondary_color: string;
|
||||
danger_color: string;
|
||||
header_footer_bg_color: string;
|
||||
success_color: string;
|
||||
header_bg_color: string;
|
||||
footer_bg_color: string;
|
||||
header_logo: string;
|
||||
footer_logo: string;
|
||||
selected_bank_account_id?: number | null;
|
||||
selected_bank_account?: BankAccount | null;
|
||||
hero_config?: HeroConfig | null;
|
||||
event_config?: EventConfig | null;
|
||||
search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart';
|
||||
search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel';
|
||||
search_items_per_page?: number;
|
||||
main_carousel_images?: string[];
|
||||
social_media?: SocialMedia[];
|
||||
menues?: Menu[];
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
export interface TenantBootstrapResponse {
|
||||
data: Tenant;
|
||||
}
|
||||
export type TenantBootstrapResponse = ApiResponse<Tenant>;
|
||||
|
||||
@@ -1,15 +1,52 @@
|
||||
import { PLATFORM_ID } from '@angular/core';
|
||||
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 { TenantBootstrapResponse } from './tenant.interface';
|
||||
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(() => {
|
||||
window.history.replaceState({}, '', 'http://localhost:4200/store');
|
||||
try {
|
||||
window.history.replaceState({}, '', 'http://localhost:4200/');
|
||||
} catch (e) {
|
||||
// Ignore SecurityError under test environments
|
||||
}
|
||||
});
|
||||
|
||||
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
|
||||
@@ -21,30 +58,124 @@ describe('TenantService', () => {
|
||||
const httpController = TestBed.inject(HttpTestingController);
|
||||
|
||||
const bootstrapPromise = service.bootstrap();
|
||||
const request = httpController.expectOne(`${environment.url}tennatns/bootstrap/localhost`);
|
||||
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`);
|
||||
|
||||
expect(request.request.method).toBe('GET');
|
||||
|
||||
const response: TenantBootstrapResponse = {
|
||||
data: {
|
||||
id: 1,
|
||||
codigo: 'test',
|
||||
nombre: 'Test Tennant',
|
||||
dominio: 'localhost',
|
||||
primary_color: '#6376F3',
|
||||
secondary_color: '#A0A0A0',
|
||||
danger_color: '#FF8888',
|
||||
header_footer_bg_color: '#313131',
|
||||
header_logo: 'https://example.com/header.png',
|
||||
footer_logo: 'https://example.com/footer.png'
|
||||
request.flush(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 }
|
||||
]
|
||||
});
|
||||
|
||||
request.flush(response);
|
||||
const service = TestBed.inject(TenantService);
|
||||
const transferState = TestBed.inject(TransferState);
|
||||
const httpController = TestBed.inject(HttpTestingController);
|
||||
|
||||
await expect(bootstrapPromise).resolves.toEqual(response.data);
|
||||
expect(service.tenant()).toEqual(response.data);
|
||||
expect(service.getTenant()).toEqual(response.data);
|
||||
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();
|
||||
});
|
||||
@@ -58,33 +189,50 @@ describe('TenantService', () => {
|
||||
const httpController = TestBed.inject(HttpTestingController);
|
||||
|
||||
const bootstrapPromise = service.bootstrap();
|
||||
const request = httpController.expectOne(`${environment.url}tennatns/bootstrap/localhost`);
|
||||
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('skips the HTTP bootstrap on the server platform', async () => {
|
||||
it('propagates non-404 backend failures', async () => {
|
||||
const responseInit: ResponseInit = {};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
TenantService,
|
||||
{ provide: PLATFORM_ID, useValue: 'server' }
|
||||
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||
{
|
||||
provide: REQUEST,
|
||||
useValue: new Request('https://internal.example/render', {
|
||||
headers: {
|
||||
host: 'broken.example.com'
|
||||
}
|
||||
})
|
||||
},
|
||||
{ provide: RESPONSE_INIT, useValue: responseInit }
|
||||
]
|
||||
});
|
||||
|
||||
const service = TestBed.inject(TenantService);
|
||||
const httpController = TestBed.inject(HttpTestingController);
|
||||
|
||||
await expect(service.bootstrap()).resolves.toBeNull();
|
||||
expect(service.tenant()).toBeNull();
|
||||
expect(service.getTenant()).toBeNull();
|
||||
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.expectNone(() => true);
|
||||
httpController.verify();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,51 +1,177 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
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 {
|
||||
private readonly http = inject(HttpClient);
|
||||
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();
|
||||
}
|
||||
|
||||
async bootstrap(): Promise<Tenant | null> {
|
||||
if (!isPlatformBrowser(this.platformId)) {
|
||||
return null;
|
||||
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}`)
|
||||
this.http.get<TenantBootstrapResponse>(
|
||||
`${environment.url}tenants/bootstrap/${domain}`
|
||||
)
|
||||
);
|
||||
|
||||
if (!response?.data) {
|
||||
throw new Error(`Tenant bootstrap returned no data for domain "${domain}".`);
|
||||
}
|
||||
|
||||
this.tenantState.set(response.data);
|
||||
this.setReady(response.data);
|
||||
this.persistSsrState({
|
||||
status: 'ready',
|
||||
tenant: response.data
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.isNotFoundError(error)) {
|
||||
this.setNotFound();
|
||||
this.persistSsrState({ status: 'not-found' });
|
||||
|
||||
return response.data;
|
||||
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 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));
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useMutation } from './use-mutation';
|
||||
|
||||
describe('useMutation', () => {
|
||||
it('stores the resolved value and clears loading after a successful execution', async () => {
|
||||
const mutation = useMutation<{ id: number }, { ok: boolean; id: number }>();
|
||||
const factory = vi.fn((payload: { id: number }) => of({ ok: true, id: payload.id }));
|
||||
|
||||
await expect(mutation.execute(factory, { id: 7 })).resolves.toEqual({ ok: true, id: 7 });
|
||||
|
||||
expect(factory).toHaveBeenCalledWith({ id: 7 });
|
||||
expect(mutation.loading()).toBe(false);
|
||||
expect(mutation.error()).toBeNull();
|
||||
expect(mutation.data()).toEqual({ ok: true, id: 7 });
|
||||
});
|
||||
|
||||
it('stores the thrown error and clears loading after a failed execution', async () => {
|
||||
const mutation = useMutation<{ id: number }, { ok: boolean }>();
|
||||
const failure = new Error('request failed');
|
||||
const factory = vi.fn(() => throwError(() => failure));
|
||||
|
||||
await expect(mutation.execute(factory, { id: 7 })).rejects.toThrow('request failed');
|
||||
|
||||
expect(factory).toHaveBeenCalledWith({ id: 7 });
|
||||
expect(mutation.loading()).toBe(false);
|
||||
expect(mutation.error()).toBe(failure);
|
||||
expect(mutation.data()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { signal, WritableSignal } from '@angular/core';
|
||||
import { Observable, firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface UseMutationResult<TData, TResult> {
|
||||
loading: WritableSignal<boolean>;
|
||||
error: WritableSignal<unknown | null>;
|
||||
data: WritableSignal<TResult | null>;
|
||||
execute: (factory: (payload: TData) => Observable<TResult>, payload: TData) => Promise<TResult>;
|
||||
}
|
||||
|
||||
export function useMutation<TData, TResult>(): UseMutationResult<TData, TResult> {
|
||||
const loading = signal(false);
|
||||
const error = signal<unknown | null>(null);
|
||||
const data = signal<TResult | null>(null);
|
||||
|
||||
async function execute(
|
||||
factory: (payload: TData) => Observable<TResult>,
|
||||
payload: TData
|
||||
): Promise<TResult> {
|
||||
loading.set(true);
|
||||
error.set(null);
|
||||
|
||||
try {
|
||||
const result = await firstValueFrom(factory(payload));
|
||||
data.set(result);
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
error.set(err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
data,
|
||||
execute
|
||||
};
|
||||
}
|
||||
@@ -3,9 +3,7 @@
|
||||
<div class="card-body">
|
||||
<span class="eyebrow">Componentes reutilizables</span>
|
||||
<h1>Button + Input</h1>
|
||||
<p class="lead mb-4">
|
||||
Pagina dedicada a probar variantes de los componentes compartidos.
|
||||
</p>
|
||||
<p class="lead mb-4">Pagina dedicada a probar variantes de los componentes compartidos.</p>
|
||||
|
||||
<div class="button-showcase">
|
||||
<div class="button-group">
|
||||
@@ -24,6 +22,7 @@
|
||||
<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>
|
||||
@@ -40,10 +39,140 @@
|
||||
<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>
|
||||
@@ -58,12 +187,7 @@
|
||||
|
||||
<div class="input-field">
|
||||
<label for="number-input">Numero</label>
|
||||
<app-input
|
||||
id="number-input"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
[(value)]="numberValue"
|
||||
/>
|
||||
<app-input id="number-input" type="number" placeholder="0" [(value)]="numberValue" />
|
||||
</div>
|
||||
|
||||
<div class="input-field">
|
||||
@@ -78,12 +202,7 @@
|
||||
|
||||
<div class="input-field">
|
||||
<label for="user-input">Usuario</label>
|
||||
<app-input
|
||||
id="user-input"
|
||||
type="user"
|
||||
placeholder="Nombre de usuario"
|
||||
[(value)]="user"
|
||||
/>
|
||||
<app-input id="user-input" type="user" placeholder="Nombre de usuario" [(value)]="user" />
|
||||
</div>
|
||||
|
||||
<div class="input-field">
|
||||
@@ -108,22 +227,12 @@
|
||||
|
||||
<div class="input-field">
|
||||
<label for="amount-input">Moneda</label>
|
||||
<app-input
|
||||
id="amount-input"
|
||||
type="currency"
|
||||
placeholder="0"
|
||||
[(value)]="amount"
|
||||
/>
|
||||
<app-input id="amount-input" type="currency" placeholder="0" [(value)]="amount" />
|
||||
</div>
|
||||
|
||||
<div class="input-field">
|
||||
<label for="percent-input">Porcentaje</label>
|
||||
<app-input
|
||||
id="percent-input"
|
||||
type="percent"
|
||||
placeholder="0"
|
||||
[(value)]="percent"
|
||||
/>
|
||||
<app-input id="percent-input" type="percent" placeholder="0" [(value)]="percent" />
|
||||
</div>
|
||||
|
||||
<div class="input-field">
|
||||
@@ -174,6 +283,493 @@
|
||||
<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>
|
||||
|
||||
@@ -15,6 +15,36 @@
|
||||
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;
|
||||
@@ -45,16 +75,92 @@ h1 {
|
||||
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;
|
||||
}
|
||||
@@ -75,6 +181,11 @@ h1 {
|
||||
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));
|
||||
@@ -82,7 +193,177 @@ h1 {
|
||||
}
|
||||
|
||||
@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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,69 @@
|
||||
import { Component } from '@angular/core';
|
||||
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, InputComponent],
|
||||
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'
|
||||
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';
|
||||
@@ -24,6 +79,197 @@ export class ReutilizablesTestPageComponent {
|
||||
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 ?? '';
|
||||
@@ -33,4 +279,96 @@ export class ReutilizablesTestPageComponent {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
CatalogItemVariant,
|
||||
InventoryPolicy,
|
||||
ProductAttribute,
|
||||
ProductAttributeOption,
|
||||
} from '../../../../core/services/catalog/catalog.interface';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-attribute-selector',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './product-attribute-selector.component.html',
|
||||
styleUrl: './product-attribute-selector.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProductAttributeSelectorComponent {
|
||||
public attributes = input<ProductAttribute[]>([]);
|
||||
public variants = input<CatalogItemVariant[]>([]);
|
||||
public selectedVariant = input<CatalogItemVariant | null>(null);
|
||||
public inventoryPolicy = input.required<InventoryPolicy>();
|
||||
|
||||
public variantChange = output<CatalogItemVariant | null>();
|
||||
|
||||
protected readonly selectedAttributeOptions = signal<Record<number, number>>({});
|
||||
|
||||
protected readonly availableOptions = computed(() => {
|
||||
const selections = this.selectedAttributeOptions();
|
||||
const variants = this.variants();
|
||||
const attributes = this.attributes();
|
||||
|
||||
const availability: Record<number, Record<number, boolean>> = {};
|
||||
|
||||
for (const attribute of attributes) {
|
||||
availability[attribute.id] = {};
|
||||
for (const option of attribute.options) {
|
||||
const optionNormalized = this.normalizeText(option.value || option.label);
|
||||
|
||||
const isAvailable = variants.some((variant) => {
|
||||
if (!this.isVariantAvailable(variant)) return false;
|
||||
|
||||
const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
|
||||
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const otherAttr of attributes) {
|
||||
if (otherAttr.id === attribute.id) continue;
|
||||
const selectedOptionId = selections[otherAttr.id];
|
||||
if (selectedOptionId !== undefined) {
|
||||
const selectedOption = otherAttr.options.find((o) => o.id === selectedOptionId);
|
||||
if (selectedOption) {
|
||||
const selectedNormalized = this.normalizeText(
|
||||
selectedOption.value || selectedOption.label,
|
||||
);
|
||||
const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.values);
|
||||
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
availability[attribute.id][option.id] = isAvailable;
|
||||
}
|
||||
}
|
||||
|
||||
return availability;
|
||||
});
|
||||
|
||||
public reset(): void {
|
||||
this.initializeSelections(this.attributes(), this.selectedVariant());
|
||||
}
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const attributes = this.attributes();
|
||||
const defaultVariant = this.selectedVariant();
|
||||
|
||||
untracked(() => {
|
||||
this.initializeSelections(attributes, defaultVariant);
|
||||
});
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const selections = this.selectedAttributeOptions();
|
||||
const variants = this.variants();
|
||||
const attributes = this.attributes();
|
||||
|
||||
untracked(() => {
|
||||
this.emitMatchingVariant(selections, variants, attributes);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected hasSelectedOption(
|
||||
attribute: ProductAttribute,
|
||||
option: ProductAttributeOption,
|
||||
): boolean {
|
||||
return this.selectedAttributeOptions()[attribute.id] === option.id;
|
||||
}
|
||||
|
||||
protected selectAttributeOption(
|
||||
attribute: ProductAttribute,
|
||||
option: ProductAttributeOption,
|
||||
): void {
|
||||
this.selectedAttributeOptions.update((current) => ({
|
||||
...current,
|
||||
[attribute.id]: option.id,
|
||||
}));
|
||||
}
|
||||
|
||||
protected isColorOption(option: ProductAttributeOption): boolean {
|
||||
return this.findFirstHexValue(option.metadata) !== null;
|
||||
}
|
||||
|
||||
protected getOptionSwatchColor(option: ProductAttributeOption): string {
|
||||
return this.findFirstHexValue(option.metadata) ?? '#D9D9D9';
|
||||
}
|
||||
|
||||
private initializeSelections(
|
||||
attributes: ProductAttribute[],
|
||||
defaultVariant: CatalogItemVariant | null,
|
||||
): void {
|
||||
const selections: Record<number, number> = {};
|
||||
|
||||
for (const attribute of attributes) {
|
||||
const optionId = this.findDefaultOptionId(attribute, defaultVariant);
|
||||
if (optionId !== null) {
|
||||
selections[attribute.id] = optionId;
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedAttributeOptions.set(selections);
|
||||
}
|
||||
|
||||
private findDefaultOptionId(
|
||||
attribute: ProductAttribute,
|
||||
variant: CatalogItemVariant | null,
|
||||
): number | null {
|
||||
if (!variant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultValue = this.getDefaultVariantAttributeValue(attribute, variant.values);
|
||||
if (!defaultValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedValue = this.normalizeText(defaultValue);
|
||||
const matchByValue = attribute.options.find(
|
||||
(option) => this.normalizeText(option.value) === normalizedValue,
|
||||
);
|
||||
|
||||
if (matchByValue) {
|
||||
return matchByValue.id;
|
||||
}
|
||||
|
||||
const matchByLabel = attribute.options.find(
|
||||
(option) => this.normalizeText(option.label) === normalizedValue,
|
||||
);
|
||||
|
||||
return matchByLabel?.id ?? null;
|
||||
}
|
||||
|
||||
private getDefaultVariantAttributeValue(
|
||||
attribute: ProductAttribute,
|
||||
variantAttributes: Record<string, string>,
|
||||
): string | null {
|
||||
const normalizedCodigo = this.normalizeText(attribute.codigo);
|
||||
const normalizedNombre = this.normalizeText(attribute.nombre);
|
||||
|
||||
for (const [key, value] of Object.entries(variantAttributes)) {
|
||||
const normalizedKey = this.normalizeText(key);
|
||||
|
||||
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private normalizeText(value: string | null | undefined): string {
|
||||
return (value ?? '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
private isVariantAvailable(variant: CatalogItemVariant): boolean {
|
||||
return this.inventoryPolicy() === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
|
||||
}
|
||||
|
||||
private findFirstHexValue(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const match = value.match(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = this.findFirstHexValue(item);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
for (const item of Object.values(value)) {
|
||||
const found = this.findFirstHexValue(item);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private emitMatchingVariant(
|
||||
selections: Record<number, number>,
|
||||
variants: CatalogItemVariant[],
|
||||
attributes: ProductAttribute[],
|
||||
): void {
|
||||
if (attributes.length === 0 || variants.length === 0) {
|
||||
this.variantChange.emit(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedValuesById: Record<number, string> = {};
|
||||
let allSelected = true;
|
||||
for (const attr of attributes) {
|
||||
const selectedOptionId = selections[attr.id];
|
||||
if (selectedOptionId === undefined) {
|
||||
allSelected = false;
|
||||
break;
|
||||
}
|
||||
const option = attr.options.find((o) => o.id === selectedOptionId);
|
||||
if (option) {
|
||||
selectedValuesById[attr.id] = this.normalizeText(option.value || option.label);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allSelected) {
|
||||
this.variantChange.emit(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingVariant = variants.find((variant) => {
|
||||
return attributes.every((attr) => {
|
||||
const selectedValue = selectedValuesById[attr.id];
|
||||
const variantValue = this.getDefaultVariantAttributeValue(attr, variant.values);
|
||||
if (!variantValue) return false;
|
||||
return this.normalizeText(variantValue) === selectedValue;
|
||||
});
|
||||
});
|
||||
|
||||
this.variantChange.emit(matchingVariant || null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<div class="product-carousel d-flex flex-column gap-4">
|
||||
<!-- Active/Main Image Area -->
|
||||
<div class="product-carousel__main position-relative bg-light overflow-hidden">
|
||||
<!-- Main Image -->
|
||||
@if (images().length > 0) {
|
||||
<img
|
||||
[ngSrc]="images()[activeIndex()]"
|
||||
alt="Product active image"
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 992px) 42vw, 100vw"
|
||||
class="product-carousel__main-image w-100 h-100 object-fit-cover"
|
||||
/>
|
||||
} @else {
|
||||
<!-- Placeholder -->
|
||||
<div
|
||||
class="product-carousel__placeholder w-100 h-100 d-flex align-items-center justify-content-center text-muted"
|
||||
>
|
||||
<i class="fa-solid fa-image fa-3x"></i>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Discount Badge -->
|
||||
@if (discount() && discount()! > 0) {
|
||||
<span
|
||||
class="product-carousel__discount-badge position-absolute top-0 end-0 bg-primary text-white px-3 py-2 fw-semibold"
|
||||
>
|
||||
-{{ discount() }}%
|
||||
</span>
|
||||
}
|
||||
|
||||
<!-- Navigation Arrows -->
|
||||
@if (images().length > 1) {
|
||||
<button
|
||||
type="button"
|
||||
class="product-carousel__nav-btn product-carousel__nav-btn--prev position-absolute top-50 translate-middle-y border-0 p-0 d-flex align-items-center justify-content-center"
|
||||
(click)="prevImage()"
|
||||
aria-label="Previous image"
|
||||
>
|
||||
<i class="fa-solid fa-chevron-left"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="product-carousel__nav-btn product-carousel__nav-btn--next position-absolute top-50 translate-middle-y border-0 p-0 d-flex align-items-center justify-content-center"
|
||||
(click)="nextImage()"
|
||||
aria-label="Next image"
|
||||
>
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Thumbnails Row -->
|
||||
@if (images().length > 1) {
|
||||
<div class="product-carousel__thumbnails">
|
||||
@for (image of images(); track image; let idx = $index) {
|
||||
<button
|
||||
type="button"
|
||||
class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light"
|
||||
[class.product-carousel__thumbnail--active]="idx === activeIndex()"
|
||||
(click)="selectImage(idx)"
|
||||
[attr.aria-label]="'Select image ' + (idx + 1)"
|
||||
>
|
||||
<img [src]="image" alt="Product thumbnail" class="w-100 h-100 object-fit-cover" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,92 @@
|
||||
.product-carousel {
|
||||
width: 100%;
|
||||
|
||||
&__main {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 100%;
|
||||
background-color: #e0e0e0 !important;
|
||||
/* Matches grey placeholder in mock */
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__main-image {
|
||||
display: block;
|
||||
padding: 0;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
height: 100%;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
&__discount-badge {
|
||||
top: 0 !important;
|
||||
right: 0 !important;
|
||||
background-color: var(--tenant-primary, #6376F3) !important;
|
||||
border-bottom-left-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 0.5rem 1rem !important;
|
||||
}
|
||||
|
||||
&__nav-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.2) !important;
|
||||
color: #ffffff !important;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.4) !important;
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
}
|
||||
|
||||
&--prev {
|
||||
left: 16px !important;
|
||||
}
|
||||
|
||||
&--next {
|
||||
right: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__thumbnails {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__thumbnail {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background-color: #e0e0e0 !important;
|
||||
/* Matches mock layout */
|
||||
padding: 0;
|
||||
transition: opacity 0.2s ease, border-color 0.2s ease;
|
||||
border: 3px solid transparent !important;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: var(--tenant-primary, #6376F3) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ProductCarouselComponent } from './product-carousel.component';
|
||||
|
||||
describe('ProductCarouselComponent', () => {
|
||||
const mockImages = [
|
||||
'https://example.com/img1.png',
|
||||
'https://example.com/img2.png',
|
||||
'https://example.com/img3.png'
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
async function createComponent(images: string[] = [], discount: number | null = null) {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProductCarouselComponent]
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(ProductCarouselComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
// Set signals input values
|
||||
fixture.componentRef.setInput('images', images);
|
||||
fixture.componentRef.setInput('discount', discount);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
return { fixture, component };
|
||||
}
|
||||
|
||||
it('should create and start at activeIndex 0', async () => {
|
||||
const { component } = await createComponent(mockImages);
|
||||
expect(component).toBeTruthy();
|
||||
expect(component.activeIndex()).toBe(0);
|
||||
});
|
||||
|
||||
it('should cycle through images on nextImage', async () => {
|
||||
const { component } = await createComponent(mockImages);
|
||||
expect(component.activeIndex()).toBe(0);
|
||||
|
||||
component.nextImage();
|
||||
expect(component.activeIndex()).toBe(1);
|
||||
|
||||
component.nextImage();
|
||||
expect(component.activeIndex()).toBe(2);
|
||||
|
||||
component.nextImage(); // Should wrap around to 0
|
||||
expect(component.activeIndex()).toBe(0);
|
||||
});
|
||||
|
||||
it('should cycle backwards on prevImage', async () => {
|
||||
const { component } = await createComponent(mockImages);
|
||||
expect(component.activeIndex()).toBe(0);
|
||||
|
||||
component.prevImage(); // Should wrap around to end (2)
|
||||
expect(component.activeIndex()).toBe(2);
|
||||
|
||||
component.prevImage();
|
||||
expect(component.activeIndex()).toBe(1);
|
||||
});
|
||||
|
||||
it('should set activeIndex on selectImage', async () => {
|
||||
const { component } = await createComponent(mockImages);
|
||||
expect(component.activeIndex()).toBe(0);
|
||||
|
||||
component.selectImage(2);
|
||||
expect(component.activeIndex()).toBe(2);
|
||||
|
||||
// If out of bounds, should not change
|
||||
component.selectImage(5);
|
||||
expect(component.activeIndex()).toBe(2);
|
||||
});
|
||||
|
||||
it('should render discount badge if provided', async () => {
|
||||
const { fixture } = await createComponent(mockImages, 20);
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const badge = element.querySelector('.product-carousel__discount-badge');
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge?.textContent?.trim()).toBe('-20%');
|
||||
});
|
||||
|
||||
it('should not render discount badge if not provided', async () => {
|
||||
const { fixture } = await createComponent(mockImages);
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const badge = element.querySelector('.product-carousel__discount-badge');
|
||||
expect(badge).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core';
|
||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-carousel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgOptimizedImage],
|
||||
templateUrl: './product-carousel.component.html',
|
||||
styleUrl: './product-carousel.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProductCarouselComponent {
|
||||
readonly images = input<string[]>([]);
|
||||
readonly discount = input<number | null>(null);
|
||||
|
||||
readonly activeIndex = signal(0);
|
||||
|
||||
nextImage(): void {
|
||||
const total = this.images().length;
|
||||
if (total > 0) {
|
||||
this.activeIndex.set((this.activeIndex() + 1) % total);
|
||||
}
|
||||
}
|
||||
|
||||
prevImage(): void {
|
||||
const total = this.images().length;
|
||||
if (total > 0) {
|
||||
this.activeIndex.set((this.activeIndex() - 1 + total) % total);
|
||||
}
|
||||
}
|
||||
|
||||
selectImage(index: number): void {
|
||||
const total = this.images().length;
|
||||
if (index >= 0 && index < total) {
|
||||
this.activeIndex.set(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<div class="sidebar-container">
|
||||
<h2 class="sidebar-title">{{ menu()?.label }}</h2>
|
||||
<div class="divider"></div>
|
||||
<nav class="sidebar-nav" aria-label="Secciones de mi cuenta">
|
||||
@for (menu of submenues(); track menu.code) {
|
||||
<a [routerLink]="menu.route" routerLinkActive="active" class="nav-link">
|
||||
{{ menu.label }}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
.sidebar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sidebar-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: #e0e0e0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: #5a5a5a;
|
||||
}
|
||||
.nav-link.active {
|
||||
color: var(--color-primary); /* Blue color matching the image */
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Tenant } from '../../../../../../core/services/tenant.interface';
|
||||
import { TenantService } from '../../../../../../core/services/tenant.service';
|
||||
import { AccountSidebar } from './account-sidebar';
|
||||
|
||||
describe('AccountSidebar', () => {
|
||||
it('renders only the account submenus configured for the tenant', async () => {
|
||||
const tenant = signal<Tenant>({
|
||||
id: 1,
|
||||
codigo: 'test',
|
||||
nombre: 'Test',
|
||||
dominio: 'localhost',
|
||||
primary_color: '#000000',
|
||||
secondary_color: '#000000',
|
||||
danger_color: '#000000',
|
||||
success_color: '#000000',
|
||||
header_bg_color: '#ffffff',
|
||||
footer_bg_color: '#ffffff',
|
||||
header_logo: '',
|
||||
footer_logo: '',
|
||||
categories: [],
|
||||
menues: [
|
||||
{
|
||||
id: 1,
|
||||
code: 'account',
|
||||
label: 'Mi cuenta',
|
||||
parent_menu_code: null,
|
||||
content_type: 'dynamic',
|
||||
route: '/mi-cuenta',
|
||||
submenues: [
|
||||
{
|
||||
id: 2,
|
||||
code: 'account.profile',
|
||||
label: 'Datos personales',
|
||||
parent_menu_code: 'account',
|
||||
content_type: 'dynamic',
|
||||
route: '/mi-cuenta/datos-personales',
|
||||
submenues: [],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
code: 'account.purchases',
|
||||
label: 'Mis compras',
|
||||
parent_menu_code: 'account',
|
||||
content_type: 'dynamic',
|
||||
route: '/mi-cuenta/compras',
|
||||
submenues: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AccountSidebar],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: TenantService,
|
||||
useValue: { tenant: tenant.asReadonly() },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(AccountSidebar);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const links = Array.from(element.querySelectorAll<HTMLAnchorElement>('.nav-link'));
|
||||
|
||||
expect(element.querySelector('.sidebar-title')?.textContent?.trim()).toBe('Mi cuenta');
|
||||
expect(links.map((link) => link.textContent?.trim())).toEqual([
|
||||
'Datos personales',
|
||||
'Mis compras',
|
||||
]);
|
||||
expect(links.map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/mi-cuenta/datos-personales',
|
||||
'/mi-cuenta/compras',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
|
||||
import { findMenu } from '../../../../../../core/services/menu.utils';
|
||||
import { TenantService } from '../../../../../../core/services/tenant.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-account-sidebar',
|
||||
standalone: true,
|
||||
imports: [RouterLink, RouterLinkActive],
|
||||
templateUrl: './account-sidebar.html',
|
||||
styleUrl: './account-sidebar.scss',
|
||||
})
|
||||
export class AccountSidebar {
|
||||
private readonly tenantService = inject(TenantService);
|
||||
|
||||
protected readonly menu = computed(() =>
|
||||
findMenu(this.tenantService.tenant()?.menues ?? [], 'account'),
|
||||
);
|
||||
protected readonly submenues = computed(() => this.menu()?.submenues ?? []);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<form [formGroup]="profileForm" class="profile-form" (ngSubmit)="onSubmit()">
|
||||
<div class="form-group">
|
||||
<label>Nombre y Apellido:</label>
|
||||
<app-input
|
||||
type="text"
|
||||
[value]="profileForm.controls['fullName'].value"
|
||||
(valueChange)="profileForm.controls['fullName'].setValue($event)"
|
||||
></app-input>
|
||||
@if (getControlError('fullName'); as errorMessage) {
|
||||
<small class="text-danger">{{ errorMessage }}</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Email:</label>
|
||||
<app-input
|
||||
type="email"
|
||||
[value]="profileForm.controls['email'].value"
|
||||
(valueChange)="profileForm.controls['email'].setValue($event)"
|
||||
></app-input>
|
||||
@if (getControlError('email'); as errorMessage) {
|
||||
<small class="text-danger">{{ errorMessage }}</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>DNI:</label>
|
||||
<app-input
|
||||
type="text"
|
||||
[value]="profileForm.controls['dni'].value"
|
||||
(valueChange)="profileForm.controls['dni'].setValue($event)"
|
||||
></app-input>
|
||||
@if (getControlError('dni'); as errorMessage) {
|
||||
<small class="text-danger">{{ errorMessage }}</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Teléfono:</label>
|
||||
<app-input
|
||||
type="text"
|
||||
[value]="profileForm.controls['phone'].value"
|
||||
(valueChange)="profileForm.controls['phone'].setValue($event)"
|
||||
></app-input>
|
||||
@if (getControlError('phone'); as errorMessage) {
|
||||
<small class="text-danger">{{ errorMessage }}</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Contraseña (dejar en blanco para no modificar):</label>
|
||||
<app-input
|
||||
type="password"
|
||||
[value]="profileForm.controls['password'].value"
|
||||
(valueChange)="profileForm.controls['password'].setValue($event)"
|
||||
></app-input>
|
||||
@if (getControlError('password'); as errorMessage) {
|
||||
<small class="text-danger">{{ errorMessage }}</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-actions mt-3">
|
||||
<app-button type="button" variant="cancel" hostClass="action-btn" buttonClass="w-100" (click)="onCancel()">Cancelar</app-button>
|
||||
<app-button type="submit" variant="primary" hostClass="action-btn" buttonClass="w-100" [disabled]="isSubmitting()">Guardar</app-button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,15 @@
|
||||
.profile-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Component, inject, effect, signal, OnInit } from '@angular/core';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { InputComponent } from '../../../../../../shared/components/input/input.component';
|
||||
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
|
||||
import { AuthService } from '../../../../../../core/services/auth/auth.service';
|
||||
import { ToastService } from '../../../../../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile-form',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, InputComponent, ButtonComponent],
|
||||
templateUrl: './profile-form.html',
|
||||
styleUrl: './profile-form.scss',
|
||||
})
|
||||
export class ProfileForm implements OnInit {
|
||||
profileForm: FormGroup;
|
||||
private authService = inject(AuthService);
|
||||
private toastService = inject(ToastService);
|
||||
isSubmitting = signal(false);
|
||||
|
||||
constructor(private fb: FormBuilder) {
|
||||
this.profileForm = this.fb.group({
|
||||
fullName: ['', [Validators.required, Validators.maxLength(255)]],
|
||||
email: ['', [Validators.required, Validators.email, Validators.maxLength(255)]],
|
||||
dni: ['', [Validators.pattern(/^[0-9]{7,8}$/)]],
|
||||
phone: ['', [Validators.pattern(/^\+?[0-9\s\-]{10,20}$/)]],
|
||||
password: ['', [Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_]).{8,}$/)]]
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const user = this.authService.user();
|
||||
if (user) {
|
||||
this.profileForm.patchValue({
|
||||
fullName: user.nombre_apellido,
|
||||
email: user.email,
|
||||
dni: user.dni || '',
|
||||
phone: user.telefono || ''
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Refresh user data directly from the API to ensure consistency
|
||||
this.authService.loadCurrentUser().subscribe();
|
||||
}
|
||||
|
||||
getControlError(controlName: string): string | null {
|
||||
const control = this.profileForm.get(controlName);
|
||||
if (!control || !control.errors || (!control.touched && !this.isSubmitting())) return null;
|
||||
|
||||
if (control.errors['required']) return 'Este campo es obligatorio.';
|
||||
if (control.errors['email']) return 'Ingresa un email válido.';
|
||||
if (control.errors['maxlength']) return 'Excede el largo máximo.';
|
||||
if (controlName === 'dni' && control.errors['pattern']) return 'DNI inválido (7-8 números).';
|
||||
if (controlName === 'phone' && control.errors['pattern']) return 'Teléfono inválido.';
|
||||
if (controlName === 'password' && control.errors['pattern']) return 'Debe contener mayúscula, minúscula, carácter especial y mínimo 8 caracteres.';
|
||||
|
||||
return 'Valor inválido.';
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (this.profileForm.invalid) {
|
||||
this.profileForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting.set(true);
|
||||
const formValue = this.profileForm.value;
|
||||
const payload: any = {
|
||||
nombre_apellido: formValue.fullName,
|
||||
email: formValue.email,
|
||||
};
|
||||
if (formValue.dni) payload.dni = formValue.dni;
|
||||
if (formValue.phone) payload.telefono = formValue.phone;
|
||||
if (formValue.password) payload.password = formValue.password;
|
||||
|
||||
this.authService.updateProfile(payload).subscribe({
|
||||
next: () => {
|
||||
this.isSubmitting.set(false);
|
||||
this.toastService.success('Perfil actualizado correctamente.');
|
||||
},
|
||||
error: (error: any) => {
|
||||
this.isSubmitting.set(false);
|
||||
const errorMessage = error.error?.message || 'Error al actualizar el perfil.';
|
||||
this.toastService.danger(errorMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
const user = this.authService.user();
|
||||
if (user) {
|
||||
this.profileForm.reset({
|
||||
fullName: user.nombre_apellido,
|
||||
email: user.email,
|
||||
dni: user.dni || '',
|
||||
phone: user.telefono || '',
|
||||
password: ''
|
||||
});
|
||||
} else {
|
||||
this.profileForm.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<article class="w-100 py-3 rounded-0 cart-item" [class.cart-item--without-image]="!item.imageUrl">
|
||||
@if (item.imageUrl) {
|
||||
<div class="position-relative overflow-hidden cart-item-media">
|
||||
@if (item.discountPercentage) {
|
||||
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge"
|
||||
>-{{ item.discountPercentage }}%</span
|
||||
>
|
||||
}
|
||||
|
||||
<img class="w-100 h-100 object-fit-cover d-block" [src]="item.imageUrl" [alt]="item.title" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="d-flex flex-column justify-content-between w-100 h-100 py-1 cart-item-content">
|
||||
<div class="d-grid cart-item-top-row align-items-start">
|
||||
<h3 class="m-0 text-uppercase fw-bold cart-item-product">{{ item.title }}</h3>
|
||||
|
||||
<div class="cart-item-prices">
|
||||
@if (item.originalPrice) {
|
||||
<span class="text-decoration-line-through cart-item-original-price"
|
||||
>$ {{ item.originalPrice }}</span
|
||||
>
|
||||
}
|
||||
<span class="fw-bold cart-item-discounted-price">$ {{ item.price }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (item.imageUrl) {
|
||||
<div
|
||||
class="d-grid cart-item-attributes"
|
||||
[class.cart-item-attributes--quantity-only]="item.attributes.length === 0"
|
||||
>
|
||||
<div class="cart-item-attribute">
|
||||
<span class="cart-item-attribute-label">Cantidad: </span>
|
||||
<span class="fw-bold"
|
||||
>{{ item.quantity }} unidad{{ item.quantity === 1 ? '' : 'es' }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
@for (attribute of item.attributes; track attribute.label + attribute.value) {
|
||||
<div class="cart-item-attribute">
|
||||
<span class="cart-item-attribute-label">{{ attribute.label }}: </span>
|
||||
<span class="fw-bold">{{ attribute.value }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (item.transferPrice) {
|
||||
<div class="cart-item-transfer-price">
|
||||
<span class="cart-item-price-label">Precio por transferencia</span>
|
||||
<span class="fw-bold">${{ item.transferPrice }}</span>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<p class="m-0 cart-item-description">{{ item.description }}</p>
|
||||
|
||||
<div class="d-grid cart-item-bottom-details">
|
||||
@for (attribute of item.attributes; track attribute.label + attribute.value) {
|
||||
<div class="cart-item-attribute">
|
||||
<span class="cart-item-attribute-label">{{ attribute.label }}: </span>
|
||||
<span class="fw-bold">{{ attribute.value }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cart-item-attribute">
|
||||
<span class="cart-item-attribute-label">Cantidad: </span>
|
||||
<span class="fw-bold"
|
||||
>{{ item.quantity }} unidad{{ item.quantity === 1 ? '' : 'es' }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,129 @@
|
||||
:host {
|
||||
display: block;
|
||||
border-bottom: 1px solid #dddddd;
|
||||
}
|
||||
|
||||
:host(:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
background-color: transparent;
|
||||
position: relative;
|
||||
|
||||
&--without-image {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.cart-item-media {
|
||||
width: 120px;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 5px 0 0 5px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #eaeaea;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-item-discount-badge {
|
||||
width: 44px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom-right-radius: 4px;
|
||||
background-color: var(--bs-primary, #5b75ff);
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cart-item-content {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cart-item-top-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cart-item-product {
|
||||
color: #666666;
|
||||
font-size: 10px;
|
||||
line-height: 1.25;
|
||||
font-weight: 325;
|
||||
}
|
||||
|
||||
.cart-item-prices {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.cart-item-original-price {
|
||||
color: #a0a0a0;
|
||||
font-size: 9px;
|
||||
font-weight: 325;
|
||||
}
|
||||
|
||||
.cart-item-discounted-price {
|
||||
color: var(--bs-primary, #5b75ff);
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cart-item-price-label {
|
||||
color: #a0a0a0;
|
||||
font-size: 9px;
|
||||
font-weight: 325;
|
||||
}
|
||||
|
||||
.cart-item-attributes {
|
||||
gap: 0.125rem;
|
||||
|
||||
&--quantity-only {
|
||||
flex: 1;
|
||||
align-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-item-attribute {
|
||||
color: #666666;
|
||||
font-size: 9px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cart-item-attribute-label {
|
||||
font-weight: 325;
|
||||
}
|
||||
|
||||
.cart-item-transfer-price {
|
||||
color: #666666;
|
||||
font-size: 9px;
|
||||
line-height: 1.2;
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.cart-item-description {
|
||||
color: #666666;
|
||||
font-size: 9px;
|
||||
font-weight: 325;
|
||||
line-height: 1.35;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.cart-item-bottom-details {
|
||||
gap: 0.125rem;
|
||||
justify-items: end;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { PurchaseItem, PurchaseItemViewModel } from './purchase-item';
|
||||
|
||||
describe('PurchaseItem', () => {
|
||||
let fixture: ComponentFixture<PurchaseItem>;
|
||||
|
||||
const item: PurchaseItemViewModel = {
|
||||
id: 1,
|
||||
title: 'Entrada general',
|
||||
description: 'Acceso al evento durante toda la jornada.',
|
||||
imageUrl: null,
|
||||
quantity: 2,
|
||||
attributes: [{ label: 'Turno', value: 'Tarde' }],
|
||||
price: '20000.00',
|
||||
transferPrice: '18000.00',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({ imports: [PurchaseItem] }).compileComponents();
|
||||
fixture = TestBed.createComponent(PurchaseItem);
|
||||
});
|
||||
|
||||
it('uses the image-free layout and moves description and item details', () => {
|
||||
fixture.componentInstance.item = item;
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(
|
||||
element.querySelector('.cart-item')?.classList.contains('cart-item--without-image'),
|
||||
).toBe(true);
|
||||
expect(element.querySelector('.cart-item-media')).toBeNull();
|
||||
expect(element.querySelector('.cart-item-description')?.textContent?.trim()).toBe(
|
||||
item.description,
|
||||
);
|
||||
expect(element.querySelector('.cart-item-bottom-details')?.textContent).toContain(
|
||||
'Turno: Tarde',
|
||||
);
|
||||
expect(element.querySelector('.cart-item-bottom-details')?.textContent).toContain(
|
||||
'Cantidad: 2 unidades',
|
||||
);
|
||||
expect(element.querySelector('.cart-item-transfer-price')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the existing layout when the product has an image', () => {
|
||||
fixture.componentInstance.item = {
|
||||
...item,
|
||||
imageUrl: 'https://example.com/product.png',
|
||||
};
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(
|
||||
element.querySelector('.cart-item')?.classList.contains('cart-item--without-image'),
|
||||
).toBe(false);
|
||||
expect(element.querySelector('.cart-item-media img')).not.toBeNull();
|
||||
expect(element.querySelector('.cart-item-description')).toBeNull();
|
||||
expect(element.querySelector('.cart-item-attributes')?.textContent).toContain(
|
||||
'Cantidad: 2 unidades',
|
||||
);
|
||||
expect(element.querySelector('.cart-item-transfer-price')?.textContent).toContain(
|
||||
'Precio por transferencia',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
export type PurchaseItemViewModel = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
imageUrl: string | null;
|
||||
quantity: number;
|
||||
attributes: Array<{
|
||||
label: string;
|
||||
value: string | null;
|
||||
}>;
|
||||
price: string;
|
||||
originalPrice?: string | null;
|
||||
transferPrice?: string | null;
|
||||
discountPercentage?: string | number | null;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-purchase-item',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
templateUrl: './purchase-item.html',
|
||||
styleUrl: './purchase-item.scss',
|
||||
})
|
||||
export class PurchaseItem {
|
||||
@Input() item!: PurchaseItemViewModel;
|
||||
}
|
||||