Compare commits
4 Commits
224db4527e
...
0c5f33460a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c5f33460a | ||
|
|
e950bd3ac6 | ||
|
|
042c5545f7 | ||
|
|
3c7f04870c |
1
apps/ops/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
public/tiles/
|
||||
254
apps/ops/package-lock.json
generated
|
|
@ -15,13 +15,17 @@
|
|||
"@vue-flow/core": "^1.48.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"cytoscape": "^3.33.2",
|
||||
"dompurify": "^3.4.11",
|
||||
"grapesjs": "^0.22.16",
|
||||
"grapesjs-mjml": "^1.0.8",
|
||||
"grapesjs-preset-newsletter": "^1.0.2",
|
||||
"hy-vue-gantt": "^5.2.1",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"pinia": "^2.1.7",
|
||||
"pmtiles": "^4.4.1",
|
||||
"protomaps-themes-base": "^4.5.0",
|
||||
"quasar": "^2.16.10",
|
||||
"sip.js": "^0.21.2",
|
||||
"vue": "^3.4.21",
|
||||
|
|
@ -2021,6 +2025,119 @@
|
|||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
|
||||
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/whoots-js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
|
||||
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "24.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
|
||||
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz",
|
||||
"integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
|
||||
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.0.tgz",
|
||||
"integrity": "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
|
@ -2863,6 +2980,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"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==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/har-format": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
|
||||
|
|
@ -4864,8 +4987,6 @@
|
|||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
|
|
@ -4912,6 +5033,12 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
|
|
@ -5902,8 +6029,7 @@
|
|||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/figures": {
|
||||
"version": "3.2.0",
|
||||
|
|
@ -6273,6 +6399,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
|
|
@ -7411,6 +7543,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
|
|
@ -7465,6 +7603,12 @@
|
|||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
|
||||
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
|
|
@ -7729,6 +7873,40 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "5.24.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
|
||||
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.1.0",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@mapbox/whoots-js": "^3.1.0",
|
||||
"@maplibre/geojson-vt": "^6.1.0",
|
||||
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
|
||||
"@maplibre/mlt": "^1.1.8",
|
||||
"@maplibre/vt-pbf": "^4.3.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"earcut": "^3.0.2",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.0.2",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
|
@ -7873,7 +8051,6 @@
|
|||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
|
|
@ -7915,6 +8092,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mute-stream": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
|
||||
|
|
@ -8312,6 +8495,18 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
|
||||
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
|
|
@ -8361,6 +8556,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pmtiles": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.4.1.tgz",
|
||||
"integrity": "sha512-5oTeQc/yX/ft1evbpIlnoCZugQuug/iYIAj/ZTqIqzdGek4uZEho99En890EE6NOSI3JTI3IG8R7r8+SltphxA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
|
@ -8413,6 +8617,12 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
|
@ -8459,6 +8669,19 @@
|
|||
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/protomaps-themes-base": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/protomaps-themes-base/-/protomaps-themes-base-4.5.0.tgz",
|
||||
"integrity": "sha512-z5A6oXAXNi/CHEIbCfzZ/6uVDgnv3pO4C3LFvomxjUFXwyqWZ7HJdfPoNJSIlsvaQubqsaxCbamEzqBr/W7vkg==",
|
||||
"deprecated": "This package has been migrated to @protomaps/basemaps with a new major version. See the CHANGELOG at https://github.com/protomaps/basemaps/blob/main/CHANGELOG.md for how to migrate.",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
|
|
@ -8535,6 +8758,12 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
|
|
@ -8821,6 +9050,15 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
|
||||
|
|
@ -9878,6 +10116,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -18,21 +18,24 @@
|
|||
"@vue-flow/core": "^1.48.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"cytoscape": "^3.33.2",
|
||||
"dompurify": "^3.4.11",
|
||||
"grapesjs": "^0.22.16",
|
||||
"grapesjs-mjml": "^1.0.8",
|
||||
"grapesjs-preset-newsletter": "^1.0.2",
|
||||
"hy-vue-gantt": "^5.2.1",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"pinia": "^2.1.7",
|
||||
"pmtiles": "^4.4.1",
|
||||
"protomaps-themes-base": "^4.5.0",
|
||||
"quasar": "^2.16.10",
|
||||
"sip.js": "^0.21.2",
|
||||
"vue": "^3.4.21",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-email-editor": "^2.2.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"dompurify": "^3.4.11"
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@quasar/app-vite": "^1.10.0",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ module.exports = configure(function () {
|
|||
|
||||
build: {
|
||||
target: {
|
||||
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
||||
browser: ['es2020', 'edge88', 'firefox78', 'chrome87', 'safari14'], // es2020+/safari14 : MapLibre GL v5 utilise des littéraux BigInt (0n)
|
||||
node: 'node20',
|
||||
},
|
||||
vueRouterMode: 'hash',
|
||||
|
|
|
|||
|
|
@ -27,3 +27,6 @@ export const campingsUpsert = (body) => jpost('/address/conformity/campings', bo
|
|||
export const campingsApply = () => jpost('/address/conformity/campings/apply', {})
|
||||
// Dernier repli : placer les unmatched restants au centre du code postal (sinon ville) → statut 'area'
|
||||
export const conformityApplyArea = () => jpost('/address/conformity/apply-area', {})
|
||||
|
||||
// Géocodage INVERSE (coord → adresse) via notre RQA locale (plus de Nominatim externe).
|
||||
export const reverse = (lat, lon) => jget('/address/reverse?lat=' + encodeURIComponent(lat) + '&lon=' + encodeURIComponent(lon))
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@
|
|||
<template v-if="issue?.name">{{ issue.name }} · {{ issue.subject }}</template>
|
||||
<template v-else-if="customer?.name">{{ customer.customer_name || customer.name }}</template>
|
||||
</div>
|
||||
<!-- Adresse qualifiée (wizard soumission) : le prix dépend de l'adresse → visible pendant toute la soumission -->
|
||||
<div v-if="customer?.qualified_address" class="text-caption row items-center no-wrap q-mt-xs" style="gap:4px;color:#0f766e">
|
||||
<q-icon name="place" size="14px" />{{ customer.qualified_address.address }}
|
||||
<q-chip v-if="customer.qualified_address.zone != null" dense square color="teal-1" text-color="teal-9" size="sm" class="q-ml-xs">Zone {{ customer.qualified_address.zone }}</q-chip>
|
||||
<q-icon :name="customer.qualified_address.fiber ? 'wifi' : 'wifi_off'" size="13px" :color="customer.qualified_address.fiber ? 'positive' : 'grey-5'" />
|
||||
</div>
|
||||
</div>
|
||||
<q-btn flat round dense icon="close" @click="cancel" />
|
||||
</q-card-section>
|
||||
|
|
@ -190,6 +196,21 @@
|
|||
<q-icon v-if="orderItems.length" name="arrow_forward" size="18px" color="primary" />
|
||||
</div>
|
||||
|
||||
<!-- Égaler un concurrent (terrain) : total mensuel du concurrent → rabais promo récurrent (rab-x) sur l'Internet, 24 mois -->
|
||||
<div v-if="orderItems.length" class="cw-compet q-mt-sm q-pa-sm">
|
||||
<div class="row items-center no-wrap q-gutter-sm">
|
||||
<q-icon name="sell" color="deep-orange-6" size="20px" />
|
||||
<div class="text-caption text-weight-medium col">Égaler un concurrent<div class="text-caption text-grey-6" style="font-weight:400">Total mensuel du concurrent → rabais 24 mois sur l'Internet</div></div>
|
||||
<q-input dense outlined v-model.number="competitorPrice" type="number" step="0.01" min="0" suffix="$/mois" style="width:118px" placeholder="total" @keyup.enter="applyCompetitor" />
|
||||
<q-btn dense unelevated no-caps color="deep-orange-6" label="Égaler" :disable="!(competitorPrice > 0)" @click="applyCompetitor" />
|
||||
</div>
|
||||
<div v-if="competitorInfo" class="row items-center no-wrap q-mt-xs" :class="competitorInfo.ok ? 'text-positive' : 'text-grey-7'">
|
||||
<q-icon :name="competitorInfo.ok ? 'check_circle' : 'info'" size="15px" class="q-mr-xs" />
|
||||
<span class="text-caption col">{{ competitorInfo.msg }}</span>
|
||||
<q-btn v-if="competitorInfo.ok" flat dense no-caps size="sm" color="grey-7" label="Retirer" @click="removeCompetitor" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ──────────────────────────────────────────────────────────────
|
||||
ÉTAPE 1 — Internet (obligatoire, accordion list)
|
||||
Same accordion pattern as TV/Phone: clicking the header toggles
|
||||
|
|
@ -1338,8 +1359,17 @@ function confirmAddStep () {
|
|||
|
||||
const templateLoadedFor = ref(new Set())
|
||||
|
||||
const { catalogProducts, catalogLoading, catalogFilter, catalogCategories, residentialPresets, internetHeroTiers, tvHeroTiers, selectedHeroTier, selectHeroTier, selectedTvTier, selectTvTier, filteredCatalog, loadCatalog, addCatalogItem, applyPreset, loadTemplateFromItem, toggleTemplate, removeStepFromBundle, addStepToBundle, loadedBundles, lastMergeCount, mergedTemplateLabels } =
|
||||
const { catalogProducts, catalogLoading, catalogFilter, catalogCategories, residentialPresets, internetHeroTiers, tvHeroTiers, selectedHeroTier, selectHeroTier, selectedTvTier, selectTvTier, filteredCatalog, loadCatalog, addCatalogItem, matchCompetitor, clearCompetitor, applyPreset, loadTemplateFromItem, toggleTemplate, removeStepFromBundle, addStepToBundle, loadedBundles, lastMergeCount, mergedTemplateLabels } =
|
||||
useWizardCatalog({ orderItems, wizardSteps, templates, templateLoadedFor })
|
||||
// Égaler un concurrent (terrain) : saisir le total mensuel du concurrent → rabais promo (rab-x) sur l'Internet, 24 mois.
|
||||
const competitorPrice = ref(null)
|
||||
const competitorInfo = ref(null)
|
||||
function applyCompetitor () {
|
||||
const r = matchCompetitor(competitorPrice.value, 24)
|
||||
if (r.ok) competitorInfo.value = { ok: true, msg: `Rabais −${r.rebate.toFixed(2)}$/mois sur l'Internet (24 mois) → total ${r.target.toFixed(2)}$/mois` }
|
||||
else competitorInfo.value = { ok: false, msg: (r.target >= r.base && r.base > 0) ? `Concurrent (${(r.target || 0).toFixed(2)}$) ≥ notre prix (${r.base.toFixed(2)}$) — aucun rabais requis.` : 'Ajoutez d\'abord des services au panier.' }
|
||||
}
|
||||
function removeCompetitor () { clearCompetitor(); competitorInfo.value = null; competitorPrice.value = null }
|
||||
|
||||
// Internet accordion defaults to open — Internet is required so we want
|
||||
// the tier list visible on first landing. Clicking a tier closes it.
|
||||
|
|
@ -2786,6 +2816,7 @@ watch(() => props.modelValue, (v) => {
|
|||
Sticky summary at the top of the Items/Devis scroll area. Combines
|
||||
item count + running totals + taxes into a single strip; details live
|
||||
on the Sommaire step (click → navigate). */
|
||||
.cw-compet { border: 1px solid #fde3d3; background: #fff8f4; border-radius: 10px; }
|
||||
.cart-pill {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 14px;
|
||||
|
|
|
|||
200
apps/ops/src/components/shared/QuoteWizard.vue
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<template>
|
||||
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :maximized="$q.screen.lt.md" persistent>
|
||||
<q-card class="qw-card column no-wrap">
|
||||
<q-card-section class="row items-center no-wrap q-py-sm">
|
||||
<q-icon name="request_quote" size="22px" color="teal-7" class="q-mr-sm" />
|
||||
<div class="text-h6 col ellipsis">Nouvelle soumission</div>
|
||||
<q-btn flat round dense icon="close" @click="$emit('update:modelValue', false)" />
|
||||
</q-card-section>
|
||||
|
||||
<!-- Progression (slides) -->
|
||||
<div class="qw-steps row items-center no-wrap q-px-md q-pb-sm">
|
||||
<template v-for="(s, i) in steps" :key="s.key">
|
||||
<div class="qw-step row items-center no-wrap" :class="{ done: i < stepIdx, current: i === stepIdx }">
|
||||
<div class="qw-dot row items-center justify-center"><q-icon v-if="i < stepIdx" name="check" size="14px" /><span v-else>{{ i + 1 }}</span></div>
|
||||
<div class="qw-step-lbl gt-xs">{{ s.label }}</div>
|
||||
</div>
|
||||
<div v-if="i < steps.length - 1" class="qw-line col" :class="{ done: i < stepIdx }"></div>
|
||||
</template>
|
||||
</div>
|
||||
<q-separator />
|
||||
|
||||
<q-carousel v-model="step" animated swipeable transition-prev="slide-right" transition-next="slide-left" class="qw-body col" :navigation="false" :arrows="false" keep-alive>
|
||||
<!-- 1 · ADRESSE + qualification fibre -->
|
||||
<q-carousel-slide name="adresse" class="q-pa-md column no-wrap">
|
||||
<div class="text-subtitle1 text-weight-medium">Adresse de service</div>
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Cherchez l'adresse civique — on vérifie la disponibilité de la fibre avant tout (base RQA + réseau Targo).</div>
|
||||
<q-input dense outlined v-model="addrQuery" placeholder="Ex. 123 rue Principale, Ville…" :loading="addrLoading" clearable autofocus @update:model-value="searchAddr">
|
||||
<template #prepend><q-icon name="place" /></template>
|
||||
</q-input>
|
||||
<q-list v-if="addrResults.length" bordered class="qw-results q-mt-xs">
|
||||
<q-item v-for="(a, i) in addrResults" :key="i" clickable @click="pickAddress(a)">
|
||||
<q-item-section avatar style="min-width:30px"><q-icon :name="a.fiber_available ? 'check_circle' : 'cancel'" :color="a.fiber_available ? 'positive' : 'grey-5'" /></q-item-section>
|
||||
<q-item-section><q-item-label>{{ a.address_full }}</q-item-label><q-item-label caption>{{ a.fiber_available ? ('Fibre disponible' + (a.max_speed ? ' · jusqu\'à ' + a.max_speed + ' Mbps' : '')) : 'Fibre non disponible à cette adresse' }}</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<q-banner v-if="chosen.address" class="qw-chosen q-mt-md" :class="chosen.fiber ? 'bg-green-1' : 'bg-orange-1'">
|
||||
<template #avatar><q-icon :name="chosen.fiber ? 'wifi' : 'wifi_off'" :color="chosen.fiber ? 'positive' : 'orange-8'" /></template>
|
||||
<div class="text-weight-medium">{{ chosen.address }}</div>
|
||||
<div class="text-caption">{{ chosen.fiber ? ('✓ Fibre disponible' + (chosen.maxSpeed ? ' — jusqu\'à ' + chosen.maxSpeed + ' Mbps' : '')) : '⚠ Fibre non desservie ici — soumission possible mais à valider (hors zone / à déployer).' }}</div>
|
||||
</q-banner>
|
||||
<!-- Mini-carte : repère (✓ vert) sur l'adresse trouvée → confirmation visuelle ; bouton satellite (voir le bâtiment) -->
|
||||
<div v-show="chosen.address && chosen.lat != null" class="qw-map-wrap q-mt-sm">
|
||||
<div ref="qwMapEl" class="qw-map"></div>
|
||||
<q-btn round dense size="sm" class="qw-sat-btn" :color="satellitePref ? 'primary' : 'grey-9'" text-color="white" :icon="satellitePref ? 'map' : 'satellite_alt'" @click="toggleMiniSat"><q-tooltip>{{ satellitePref ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
||||
</div>
|
||||
</q-carousel-slide>
|
||||
|
||||
<!-- 2 · CONTACT (fiche existante ou nouveau prospect) -->
|
||||
<q-carousel-slide name="contact" class="q-pa-md column no-wrap">
|
||||
<div class="text-subtitle1 text-weight-medium">Contact</div>
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Associez une fiche existante, ou créez un prospect (courriel <b>ou</b> mobile suffit pour la confirmation).</div>
|
||||
<q-input dense outlined v-model="custQuery" placeholder="Chercher par nom · téléphone · courriel…" :loading="custLoading" clearable @update:model-value="searchCust" class="q-mb-xs">
|
||||
<template #prepend><q-icon name="search" /></template>
|
||||
</q-input>
|
||||
<q-list v-if="custResults.length" bordered class="qw-results q-mb-sm">
|
||||
<q-item v-for="c in custResults" :key="c.name" clickable @click="pickCustomer(c)" :active="chosen.customer && chosen.customer.name === c.name" active-class="bg-teal-1">
|
||||
<q-item-section avatar style="min-width:30px"><q-icon name="badge" color="teal-7" /></q-item-section>
|
||||
<q-item-section><q-item-label>{{ c.customer_name || c.name }}</q-item-label><q-item-label caption>{{ [c.cell_phone || c.phone, c.email_id].filter(Boolean).join(' · ') || c.name }}</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<q-banner v-if="chosen.customer" class="bg-teal-1 q-mb-sm">
|
||||
<template #avatar><q-icon name="how_to_reg" color="teal-8" /></template>
|
||||
Fiche existante : <b>{{ chosen.customer.customer_name || chosen.customer.name }}</b>
|
||||
<template #action><q-btn flat dense no-caps label="Changer" @click="chosen.customer = null" /></template>
|
||||
</q-banner>
|
||||
<template v-else>
|
||||
<div class="text-caption text-grey-6 q-mb-xs">…ou nouveau prospect :</div>
|
||||
<q-btn-toggle v-model="lead.type" :options="[{label:'Résidentiel',value:'Individual'},{label:'Commercial',value:'Company'}]" unelevated dense no-caps toggle-color="teal-7" color="grey-3" text-color="grey-8" class="q-mb-sm" />
|
||||
<div class="row q-col-gutter-sm">
|
||||
<q-input class="col-6" dense outlined v-model="lead.first" label="Prénom" />
|
||||
<q-input class="col-6" dense outlined v-model="lead.last" :label="lead.type === 'Company' ? 'Entreprise' : 'Nom'" />
|
||||
<q-input class="col-6" dense outlined v-model="lead.email" label="Courriel" type="email" :error="showLeadErr && !leadContactOk" />
|
||||
<q-input class="col-6" dense outlined v-model="lead.mobile" label="Mobile" mask="(###) ###-####" :error="showLeadErr && !leadContactOk" />
|
||||
</div>
|
||||
<div v-if="showLeadErr && !leadContactOk" class="text-caption text-negative q-mt-xs">Au moins un courriel ou un mobile est requis.</div>
|
||||
</template>
|
||||
</q-carousel-slide>
|
||||
</q-carousel>
|
||||
|
||||
<q-separator />
|
||||
<q-card-actions class="row items-center q-px-md q-py-sm">
|
||||
<q-btn flat no-caps icon="chevron_left" label="Retour" :disable="stepIdx === 0" @click="go(stepIdx - 1)" />
|
||||
<q-space />
|
||||
<q-btn v-if="step === 'adresse'" unelevated no-caps color="teal-7" icon-right="chevron_right" label="Continuer" :disable="!chosen.address" @click="go(1)" />
|
||||
<q-btn v-else unelevated no-caps color="teal-8" icon-right="request_quote" label="Continuer vers les services" :loading="submitting" @click="finish" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useAddressSearch } from 'src/composables/useAddressSearch'
|
||||
import { createPlanMap, addSatelliteLayer, satellitePref, setSatellitePref, setSatelliteVisible } from 'src/config/basemap' // init carte MapLibre/OSM commune + satellite ESRI
|
||||
import { listDocs, createDoc } from 'src/api/erp'
|
||||
|
||||
const props = defineProps({ modelValue: Boolean })
|
||||
// « qualified » : émis avec le customer (doc ERPNext) prêt pour ProjectWizard, + l'adresse/fibre pour info.
|
||||
const emit = defineEmits(['update:modelValue', 'qualified'])
|
||||
const $q = useQuasar()
|
||||
|
||||
const steps = [{ key: 'adresse', label: 'Adresse' }, { key: 'contact', label: 'Contact' }]
|
||||
const step = ref('adresse')
|
||||
const stepIdx = computed(() => Math.max(0, steps.findIndex(s => s.key === step.value)))
|
||||
function go (i) { const n = Math.min(steps.length - 1, Math.max(0, i)); step.value = steps[n].key }
|
||||
|
||||
// ── Étape 1 : adresse + fibre ──
|
||||
const { addrResults, addrLoading, searchAddr } = useAddressSearch()
|
||||
const addrQuery = ref('')
|
||||
const chosen = reactive({ address: '', ville: '', code_postal: '', lat: null, lon: null, fiber: false, maxSpeed: null, zone: null, customer: null })
|
||||
function pickAddress (a) {
|
||||
chosen.address = a.address_full; chosen.ville = a.ville || ''; chosen.code_postal = a.code_postal || ''
|
||||
chosen.lat = a.latitude != null ? +a.latitude : null; chosen.lon = a.longitude != null ? +a.longitude : null
|
||||
chosen.fiber = !!a.fiber_available; chosen.maxSpeed = a.max_speed || null; chosen.zone = a.zone_tarifaire != null ? a.zone_tarifaire : null // zone tarifaire → le prix dépend de l'adresse
|
||||
addrResults.value = []; addrQuery.value = a.address_full
|
||||
}
|
||||
|
||||
// ── Mini-carte : repère (✓ vert) sur l'adresse trouvée (MapLibre/OSM auto-hébergé) ──
|
||||
const qwMapEl = ref(null); let _qwMap = null; let _qwMarker = null
|
||||
function initMiniMap () {
|
||||
if (!qwMapEl.value || chosen.lat == null || chosen.lon == null) return
|
||||
if (_qwMap) { _qwMap.setCenter([chosen.lon, chosen.lat]); if (_qwMarker) _qwMarker.setLngLat([chosen.lon, chosen.lat]); setTimeout(() => { try { _qwMap.resize() } catch (e) {} }, 60); return }
|
||||
_qwMap = createPlanMap(qwMapEl.value, { center: [chosen.lon, chosen.lat], zoom: 15 })
|
||||
_qwMap.on('load', () => {
|
||||
try { _qwMap.resize() } catch (e) {}
|
||||
addSatelliteLayer(_qwMap, satellitePref.value) // satellite (voir le bâtiment) — masqué par défaut, bouton pour basculer
|
||||
const el = document.createElement('div'); el.className = 'qw-pin'; el.innerHTML = '<span class="material-icons">check</span>'
|
||||
_qwMarker = new window.mapboxgl.Marker({ element: el, anchor: 'bottom' }).setLngLat([chosen.lon, chosen.lat]).addTo(_qwMap)
|
||||
})
|
||||
}
|
||||
function toggleMiniSat () { setSatellitePref(!satellitePref.value); if (_qwMap) setSatelliteVisible(_qwMap, satellitePref.value) }
|
||||
watch(satellitePref, (v) => { if (_qwMap) setSatelliteVisible(_qwMap, v) })
|
||||
function destroyMiniMap () { if (_qwMap) { try { _qwMap.remove() } catch (e) {} _qwMap = null; _qwMarker = null } }
|
||||
watch(() => [props.modelValue, step.value, chosen.lat, chosen.lon], () => {
|
||||
if (props.modelValue && step.value === 'adresse' && chosen.lat != null) nextTick(() => setTimeout(initMiniMap, 80))
|
||||
})
|
||||
watch(() => props.modelValue, (open) => { if (!open) destroyMiniMap() })
|
||||
onBeforeUnmount(destroyMiniMap)
|
||||
|
||||
// ── Étape 2 : contact (recherche fiche + création prospect) ──
|
||||
const custQuery = ref(''); const custResults = ref([]); const custLoading = ref(false); let custT = null
|
||||
const lead = reactive({ type: 'Individual', first: '', last: '', email: '', mobile: '' })
|
||||
const showLeadErr = ref(false)
|
||||
const leadContactOk = computed(() => !!(lead.email.trim() || lead.mobile.replace(/\D/g, '').length >= 10))
|
||||
function searchCust (q) {
|
||||
clearTimeout(custT); if (!q || q.trim().length < 3) { custResults.value = []; return }
|
||||
custLoading.value = true
|
||||
custT = setTimeout(async () => {
|
||||
try {
|
||||
const digits = q.replace(/\D/g, '')
|
||||
const or = [['customer_name', 'like', '%' + q + '%'], ['email_id', 'like', '%' + q + '%']]
|
||||
if (digits.length >= 4) or.push(['cell_phone', 'like', '%' + digits.slice(-10) + '%'], ['phone', 'like', '%' + digits.slice(-10) + '%'])
|
||||
const r = await listDocs('Customer', { or_filters: or, fields: ['name', 'customer_name', 'cell_phone', 'phone', 'email_id'], limit: 8 })
|
||||
custResults.value = Array.isArray(r) ? r : (r.data || [])
|
||||
} catch (e) { custResults.value = [] } finally { custLoading.value = false }
|
||||
}, 320)
|
||||
}
|
||||
function pickCustomer (c) { chosen.customer = c; custResults.value = [] }
|
||||
|
||||
const submitting = ref(false)
|
||||
async function finish () {
|
||||
if (!chosen.customer) {
|
||||
if (!leadContactOk.value) { showLeadErr.value = true; return }
|
||||
const name = [lead.first.trim(), lead.last.trim()].filter(Boolean).join(' ') || (lead.email || lead.mobile)
|
||||
submitting.value = true
|
||||
try {
|
||||
const doc = await createDoc('Customer', {
|
||||
customer_name: name, customer_type: lead.type, customer_group: lead.type === 'Company' ? 'Commercial' : 'Individual', territory: 'Canada',
|
||||
...(lead.email.trim() ? { email_id: lead.email.trim() } : {}), ...(lead.mobile.replace(/\D/g, '') ? { cell_phone: lead.mobile } : {}),
|
||||
})
|
||||
chosen.customer = doc && (doc.data || doc)
|
||||
$q.notify({ type: 'positive', icon: 'person_add', message: 'Prospect créé — ' + name, timeout: 2000 })
|
||||
} catch (e) { $q.notify({ type: 'negative', message: 'Création du prospect impossible : ' + (e.message || e) }); submitting.value = false; return }
|
||||
submitting.value = false
|
||||
}
|
||||
// Handoff : ProjectWizard (panier + rabais rab-x + devis + acceptation) prend le relais, pré-rempli.
|
||||
emit('qualified', { customer: chosen.customer, address: { address: chosen.address, ville: chosen.ville, code_postal: chosen.code_postal, lat: chosen.lat, lon: chosen.lon, fiber: chosen.fiber, zone: chosen.zone } })
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qw-card { width: 620px; max-width: 96vw; height: 70vh; max-height: 720px; }
|
||||
.q-dialog--maximized .qw-card { width: 100%; height: 100%; max-height: none; }
|
||||
.qw-steps { gap: 4px; }
|
||||
.qw-step { gap: 6px; } .qw-dot { width: 24px; height: 24px; border-radius: 50%; background: #e2e8f0; color: #64748b; font-size: 12px; font-weight: 700; flex: 0 0 auto; transition: all .2s; }
|
||||
.qw-step.current .qw-dot { background: #0f766e; color: #fff; } .qw-step.done .qw-dot { background: #14b8a6; color: #fff; }
|
||||
.qw-step-lbl { font-size: 12px; color: #64748b; font-weight: 600; } .qw-step.current .qw-step-lbl { color: #0f766e; }
|
||||
.qw-line { height: 2px; background: #e2e8f0; margin: 0 6px; border-radius: 2px; } .qw-line.done { background: #14b8a6; }
|
||||
.qw-body { min-height: 0; } .qw-body :deep(.q-carousel__slide) { overflow-y: auto; }
|
||||
.qw-results { border-radius: 8px; max-height: 260px; overflow-y: auto; }
|
||||
.qw-chosen { border-radius: 8px; }
|
||||
.qw-map-wrap { position: relative; }
|
||||
.qw-map { height: 180px; border-radius: 10px; overflow: hidden; border: 1px solid #e2e8f0; }
|
||||
.qw-sat-btn { position: absolute; top: 6px; left: 6px; z-index: 3; }
|
||||
/* Repère « adresse trouvée » : goutte verte + ✓ blanc */
|
||||
:deep(.qw-pin) { width: 30px; height: 30px; border-radius: 50% 50% 50% 0; background: #16a34a; transform: rotate(-45deg); display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 6px rgba(0,0,0,.3); border: 2px solid #fff; }
|
||||
:deep(.qw-pin .material-icons) { transform: rotate(45deg); color: #fff; font-size: 16px; }
|
||||
</style>
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
* - émet 'metrics' { id:{km,mins} } (routes réelles) ; expose fitTo(id) / fitAll() / setActive(id).
|
||||
*/
|
||||
import { watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { MAPBOX_TOKEN } from 'src/config/erpnext'
|
||||
import { installMaplibre, basemapStyle, addSatelliteLayer, satellitePref, setSatellitePref, setSatelliteVisible } from 'src/config/basemap' // fond MapLibre/OSM auto-hébergé (plus de Mapbox) + couche satellite ESRI
|
||||
import * as roster from 'src/api/roster'
|
||||
import { initials } from 'src/composables/useFormatters' // initiales (source unique, ex-locale dé-dupliquée)
|
||||
|
||||
|
|
@ -28,16 +28,7 @@ const _rmCache = RouteMapCache() // cache module (partagé entre instances) : si
|
|||
function RouteMapCache () { const g = globalThis; if (!g.__opsRouteMapCache) g.__opsRouteMapCache = new Map(); return g.__opsRouteMapCache }
|
||||
function setEl (node) { el = node }
|
||||
|
||||
function ensureMapbox () {
|
||||
return new Promise((resolve) => {
|
||||
if (!document.getElementById('mapbox-css')) { const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'; l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l) }
|
||||
if (window.mapboxgl) return resolve(window.mapboxgl)
|
||||
let s = document.getElementById('mapbox-js')
|
||||
if (!s) { s = document.createElement('script'); s.id = 'mapbox-js'; s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'; document.head.appendChild(s) }
|
||||
s.addEventListener('load', () => resolve(window.mapboxgl))
|
||||
const iv = setInterval(() => { if (window.mapboxgl) { clearInterval(iv); resolve(window.mapboxgl) } }, 150)
|
||||
})
|
||||
}
|
||||
function ensureMapbox () { return Promise.resolve(installMaplibre()) } // MapLibre bundlé + protocole pmtiles:// (alias window.mapboxgl)
|
||||
const okLL = (lon, lat) => isFinite(+lon) && isFinite(+lat) && Math.abs(+lat) > 0.01 // écarte (0,0) / invalides qui feraient dézoomer la carte
|
||||
function boundsOf (routes) {
|
||||
const b = new window.mapboxgl.LngLatBounds()
|
||||
|
|
@ -248,14 +239,14 @@ function setActive (rid) {
|
|||
defineExpose({ fitTo, fitAll, setActive })
|
||||
|
||||
onMounted(async () => {
|
||||
if (!MAPBOX_TOKEN || !el) return
|
||||
if (!el) return
|
||||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !el) return
|
||||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||||
map = new mapboxgl.Map({ container: el, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 })
|
||||
map = new mapboxgl.Map({ container: el, style: basemapStyle('light'), center: [-73.55, 45.2], zoom: 8 })
|
||||
map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
|
||||
ro = new ResizeObserver(() => { if (map) map.resize() }); ro.observe(el)
|
||||
map.on('load', () => {
|
||||
map.resize()
|
||||
addSatelliteLayer(map, satellitePref.value) // couche satellite (sous les tracés + marqueurs), masquée par défaut
|
||||
map.addSource('rm-line', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
map.addLayer({ id: 'rm-line', type: 'line', source: 'rm-line', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 3, 'line-opacity': 0.7 } })
|
||||
map.addLayer({ id: 'rm-line-a', type: 'line', source: 'rm-line', filter: ['==', ['get', 'rid'], '___aucun___'], layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 5.5, 'line-opacity': 0.95 } })
|
||||
|
|
@ -271,14 +262,22 @@ onBeforeUnmount(() => { clearMarkers(); clearLive(); clearPins(); if (ro) { try
|
|||
watch(() => props.routes, () => { draw() }, { deep: true })
|
||||
watch(() => props.live, () => { renderLive(props.live) }, { deep: true }) // rafraîchit les positions live sans redessiner les tournées
|
||||
watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // jobs non assignés (secteur) sans redessiner les tournées
|
||||
// Satellite : bouton local + préférence PARTAGÉE (satellitePref) → toutes les cartes suivent.
|
||||
function toggleSat () { setSatellitePref(!satellitePref.value); if (map) setSatelliteVisible(map, satellitePref.value) }
|
||||
watch(satellitePref, (v) => { if (map) setSatelliteVisible(map, v) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :ref="setEl" class="route-map" :style="{ height }" />
|
||||
<div class="rm-wrap" :style="{ height }">
|
||||
<div :ref="setEl" class="route-map" style="height:100%" />
|
||||
<q-btn round dense size="sm" class="rm-sat-btn" :color="satellitePref ? 'primary' : 'grey-9'" text-color="white" :icon="satellitePref ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satellitePref ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rm-wrap { position: relative; }
|
||||
.route-map { border-radius: 8px; overflow: hidden; border: 1px solid #cfd8dc; }
|
||||
.rm-sat-btn { position: absolute; top: 8px; left: 8px; z-index: 3; }
|
||||
</style>
|
||||
|
||||
<!-- NON scoped : les marqueurs sont créés en JS et injectés dans le conteneur Mapbox (hors de l'arbre scoped). Préfixe rm- = pas de collision. -->
|
||||
|
|
|
|||
9
apps/ops/src/composables/useCreateSignal.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Signal partagé « ouvrir le chooser Créer » : le FAB global (MainLayout, toutes pages) le déclenche, la
|
||||
// Planification (où vivent les flux création) l'écoute. Ref module-level → persiste à travers la navigation
|
||||
// (pas de query-route + router.replace fragile qui remonte la page et perd l'état).
|
||||
import { ref } from 'vue'
|
||||
|
||||
const pending = ref(false)
|
||||
export function useCreateSignal () {
|
||||
return { pending, requestCreate: () => { pending.value = true } }
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
// ── Map composable: Mapbox GL map, markers, routes, geo-fix, map-drag ────────
|
||||
// ── Map composable: MapLibre GL + tuiles OSM auto-hébergées (Protomaps pmtiles), markers, routes, geo-fix, map-drag ────────
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { localDateStr, jobSpansDate, jobSvcCode, SVC_COLORS } from './useHelpers'
|
||||
import * as roster from 'src/api/roster' // tracés routiers via NOTRE OSRM (plus de Mapbox Directions)
|
||||
import { installMaplibre, basemapStyle } from 'src/config/basemap' // fond de carte MapLibre/OSM (plus de Mapbox)
|
||||
|
||||
export function useMap (deps) {
|
||||
const {
|
||||
|
|
@ -70,20 +71,10 @@ export function useMap (deps) {
|
|||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||
async function initMap () {
|
||||
if (!mapContainer.value || map) return
|
||||
if (!window.mapboxgl) {
|
||||
if (!document.getElementById('mapbox-js')) {
|
||||
await new Promise(resolve => {
|
||||
const s = document.createElement('script'); s.id = 'mapbox-js'
|
||||
s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'
|
||||
s.onload = resolve; document.head.appendChild(s)
|
||||
})
|
||||
} else { await new Promise(r => setTimeout(r, 200)) }
|
||||
}
|
||||
const mapboxgl = window.mapboxgl
|
||||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||||
const mapboxgl = installMaplibre() // MapLibre bundlé + protocole pmtiles:// (alias window.mapboxgl pour compat)
|
||||
map = new mapboxgl.Map({
|
||||
container: mapContainer.value,
|
||||
style: 'mapbox://styles/mapbox/dark-v11',
|
||||
style: basemapStyle('dark'), // Dispatch = mode sombre
|
||||
// Default centered on Gigafibre HQ (1867 chemin de la Rivière,
|
||||
// Sainte-Clotilde, QC). Zoom 10 shows Sainte-Clotilde + the
|
||||
// surrounding service area (Châteauguay, Napierville, Hemmingford…).
|
||||
|
|
@ -455,12 +446,7 @@ export function useMap (deps) {
|
|||
if (map) { map.remove(); map = null }
|
||||
if (mapResizeObs) { mapResizeObs.disconnect(); mapResizeObs = null }
|
||||
}
|
||||
function loadMapboxCss () {
|
||||
if (!document.getElementById('mapbox-css')) {
|
||||
const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'
|
||||
l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l)
|
||||
}
|
||||
}
|
||||
function loadMapboxCss () { /* no-op : le CSS MapLibre est bundlé via src/config/basemap.js (plus de CDN Mapbox) */ }
|
||||
function getMap () { return map }
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,27 @@ export function useWizardCatalog ({ orderItems, wizardSteps, templates, template
|
|||
}
|
||||
}
|
||||
|
||||
// ── Égaler un prix concurrent (terrain) ──────────────────────────────────
|
||||
// Le concurrent affiche un TOTAL mensuel (tous services − rabais). On calcule le rabais promotionnel
|
||||
// récurrent (SKU rab-x) à appliquer SUR L'INTERNET pour que notre total mensuel = le sien, sur `months` (déf. 24).
|
||||
const COMP_REBATE_CODE = 'RAB_X'
|
||||
function recurringBase () { return orderItems.value.filter(i => i.billing === 'recurring' && i.item_code !== COMP_REBATE_CODE).reduce((s, i) => s + ((i.qty || 1) * i.rate), 0) }
|
||||
function clearCompetitor () { orderItems.value = orderItems.value.filter(i => i.item_code !== COMP_REBATE_CODE) }
|
||||
function matchCompetitor (competitorMonthly, months = 24) {
|
||||
const target = Number(competitorMonthly)
|
||||
const base = Math.round(recurringBase() * 100) / 100
|
||||
clearCompetitor()
|
||||
if (!(target >= 0) || target >= base) return { ok: false, base, rebate: 0, target } // rien à rabattre (concurrent ≥ notre prix)
|
||||
const hero = orderItems.value.find(i => INTERNET_HERO_CODES.has(i.item_code) && !i.is_rebate)
|
||||
const rebate = Math.round((base - target) * 100) / 100
|
||||
orderItems.value.push({
|
||||
item_code: COMP_REBATE_CODE, item_name: 'Rabais concurrentiel (' + months + ' mois)',
|
||||
qty: 1, rate: -rebate, regular_price: 0, billing: 'recurring', billing_interval: 'Month', contract_months: months,
|
||||
is_rebate: true, applies_to_item: hero ? hero.item_code : '',
|
||||
})
|
||||
return { ok: true, base, rebate, target, applied_to: hero ? hero.item_name : null }
|
||||
}
|
||||
|
||||
// Single-select hero tier swap — generic over a set of codes. Clicking a
|
||||
// tier REPLACES whatever tier from the same family is in the cart (doesn't
|
||||
// stack). Re-clicking the active tier toggles it off. `service_type` and
|
||||
|
|
@ -448,6 +469,8 @@ export function useWizardCatalog ({ orderItems, wizardSteps, templates, template
|
|||
filteredCatalog,
|
||||
loadCatalog,
|
||||
addCatalogItem,
|
||||
matchCompetitor,
|
||||
clearCompetitor,
|
||||
applyPreset,
|
||||
loadTemplateFromItem,
|
||||
removeTemplate,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ export function useWizardPublish ({ props, emit, state }) {
|
|||
return [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ')
|
||||
}
|
||||
} catch {}
|
||||
return ''
|
||||
// Repli : adresse qualifiée par le wizard soumission (prospect sans Service Location encore) — le prix dépend de l'adresse.
|
||||
const qa = props.customer?.qualified_address
|
||||
return (qa && qa.address) || ''
|
||||
}
|
||||
|
||||
async function publish () {
|
||||
|
|
@ -45,11 +47,14 @@ export function useWizardPublish ({ props, emit, state }) {
|
|||
const hasPhone = !!clientPhone.value
|
||||
const hasEmail = !!clientEmail.value
|
||||
|
||||
const qa = props.customer?.qualified_address || null
|
||||
const wizardContext = {
|
||||
issue: props.issue?.name || '',
|
||||
customer,
|
||||
address,
|
||||
service_location: serviceLocation,
|
||||
// Adresse qualifiée + zone tarifaire (le prix dépend de l'adresse) — portées sur le devis même sans Service Location.
|
||||
...(qa ? { zone_tarifaire: qa.zone ?? null, latitude: qa.lat ?? null, longitude: qa.lon ?? null, fiber_available: !!qa.fiber } : {}),
|
||||
}
|
||||
|
||||
// Step extras — each wizardStep with extra_fee > 0 becomes a one-time
|
||||
|
|
@ -376,7 +381,7 @@ export function useWizardPublish ({ props, emit, state }) {
|
|||
})
|
||||
|
||||
for (const job of createdJobs) {
|
||||
emit('created', job)
|
||||
emit('created', job, { needsAcceptance }) // needsAcceptance → le parent peut enchaîner « Trouver un créneau » quand aucun contrat signé n'est requis
|
||||
}
|
||||
|
||||
// Show success screen. Prefer Quotation as the primary artifact;
|
||||
|
|
|
|||
65
apps/ops/src/config/basemap.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Fond de carte OSM AUTO-HÉBERGÉ : MapLibre GL + tuiles Protomaps (pmtiles) servies same-origin (/ops/tiles).
|
||||
// Zéro Mapbox → aucun jeton, aucun logo, aucun appel à api.mapbox.com. La seule attribution restante est
|
||||
// « © OpenStreetMap » (exigée par la licence ODbL des données). Routage = notre OSRM ; tuiles = notre pmtiles.
|
||||
import { ref } from 'vue'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { Protocol } from 'pmtiles'
|
||||
import { layers, namedTheme } from 'protomaps-themes-base'
|
||||
|
||||
// Chemin des tuiles = base de l'app + tiles/. Dev : public/tiles/. Prod : /opt/ops-app/tiles/ servi sous /ops/tiles (Traefik strip /ops).
|
||||
export const TILES_URL = `${import.meta.env.BASE_URL || '/'}tiles/southern-quebec.pmtiles`
|
||||
// Glyphes (étiquettes) + sprites (icônes) : assets ouverts Protomaps — pas de Mapbox. {fontstack}/{range} résolus par MapLibre.
|
||||
const GLYPHS = 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf'
|
||||
const SPRITE = (t) => `https://protomaps.github.io/basemaps-assets/sprites/v4/${t}`
|
||||
|
||||
let _installed = false
|
||||
// Alias maplibregl → window.mapboxgl : l'API (Map/Marker/Popup/LngLatBounds/NavigationControl) est compatible,
|
||||
// donc tout le code existant qui référence window.mapboxgl continue de marcher sans réécriture. Enregistre aussi pmtiles://.
|
||||
export function installMaplibre () {
|
||||
if (!_installed) {
|
||||
const protocol = new Protocol()
|
||||
maplibregl.addProtocol('pmtiles', protocol.tile)
|
||||
window.mapboxgl = maplibregl // compat : marqueurs / popups / bounds existants
|
||||
_installed = true
|
||||
}
|
||||
return maplibregl
|
||||
}
|
||||
|
||||
// Crée une carte MapLibre de planification avec les réglages COMMUNS (fond OSM + bouton zoom + Street View au clic droit).
|
||||
// Réutilisé par les 4 cartes de PlanificationPage (détail job · sélecteur domicile/dépôt · panneau à assigner · éditeur jour)
|
||||
// → une seule init au lieu de 4 blocs quasi identiques. Les marqueurs/couches spécifiques restent gérés par chaque appelant.
|
||||
export function createPlanMap (el, opts = {}) {
|
||||
installMaplibre()
|
||||
const map = new maplibregl.Map({ container: el, style: basemapStyle(opts.theme || 'light'), center: opts.center || [-73.6756, 45.1599], zoom: opts.zoom != null ? opts.zoom : 9 })
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right')
|
||||
if (typeof opts.streetView === 'function') map.on('contextmenu', (e) => opts.streetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
|
||||
return map
|
||||
}
|
||||
|
||||
// Style MapLibre (thème Protomaps) : 'dark' pour le board Dispatch (exception mode sombre), 'light' pour les Tournées.
|
||||
export function basemapStyle (theme = 'light') {
|
||||
const th = theme === 'dark' ? 'dark' : 'light'
|
||||
return {
|
||||
version: 8,
|
||||
glyphs: GLYPHS,
|
||||
sprite: SPRITE(th),
|
||||
sources: { protomaps: { type: 'vector', url: 'pmtiles://' + TILES_URL, attribution: '© OpenStreetMap' } },
|
||||
layers: layers('protomaps', namedTheme(th), { lang: 'fr' }), // namedTheme() = objet flavor (v4) ; la string seule laisse les couleurs undefined
|
||||
}
|
||||
}
|
||||
|
||||
// ── Couche SATELLITE (ESRI World Imagery — libre, sans jeton). Réutilisable par toutes les cartes createPlanMap. ──
|
||||
const SAT_TILES = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
|
||||
// Préférence satellite PARTAGÉE app-wide (même clé que PlanificationPage : 'plan_sat') → cohérente entre toutes les cartes.
|
||||
export const satellitePref = ref((() => { try { return localStorage.getItem('plan_sat') === '1' } catch (e) { return false } })())
|
||||
export function setSatellitePref (on) { satellitePref.value = !!on; try { localStorage.setItem('plan_sat', on ? '1' : '0') } catch (e) {} }
|
||||
// Ajoute la couche raster satellite (sous les marqueurs, ajoutés APRÈS) + applique la visibilité. Idempotent.
|
||||
export function addSatelliteLayer (map, visible) {
|
||||
try {
|
||||
if (!map.getSource('sat')) map.addSource('sat', { type: 'raster', tiles: [SAT_TILES], tileSize: 256, attribution: '© Esri, Maxar, Earthstar Geographics' })
|
||||
if (!map.getLayer('sat')) map.addLayer({ id: 'sat', type: 'raster', source: 'sat', layout: { visibility: visible ? 'visible' : 'none' } })
|
||||
else map.setLayoutProperty('sat', 'visibility', visible ? 'visible' : 'none')
|
||||
} catch (e) { /* style pas prêt */ }
|
||||
}
|
||||
export function setSatelliteVisible (map, on) { try { if (map && map.getLayer && map.getLayer('sat')) map.setLayoutProperty('sat', 'visibility', on ? 'visible' : 'none') } catch (e) {} }
|
||||
|
|
@ -40,6 +40,8 @@ export const CATALOG_CATEGORIES = ['Tous', 'Internet', 'Téléphonie', 'Bundle',
|
|||
// -40). Higher tiers have no loyalty rebate — shown at base.
|
||||
// Combo rebates (RAB2X/RAB3X/RAB4X) are added manually from the catalog.
|
||||
export const INTERNET_HERO_TIERS = [
|
||||
// Prix EFFECTIFS après promo 24 mois (rabais récurrent RAB-LOYAUTE, rate overridé). Stratégie marketing :
|
||||
// 500 et 1 Gbps au MÊME prix (49.95) → le 1 Gbps devient le choix évident (« no brainer »). 80 = entrée de gamme.
|
||||
{
|
||||
id: 'tier_m80',
|
||||
code: 'FTTH80I',
|
||||
|
|
@ -48,39 +50,41 @@ export const INTERNET_HERO_TIERS = [
|
|||
badge: 'Économique',
|
||||
price_effective: 39.95,
|
||||
price_base: 79.95,
|
||||
desc: 'Fibre symétrique · Rabais fidélité -40$/mois',
|
||||
desc: 'Fibre symétrique · promo −40$/mois (24 mois)',
|
||||
icon: 'wifi',
|
||||
default: true,
|
||||
items: [
|
||||
{ item_code: 'FTTH80I', item_name: 'Internet Megafibre 80 Mbps', rate: 79.95, billing: 'recurring', billing_interval: 'Month', contract_months: 24, project_template_id: 'fiber_install' },
|
||||
{ item_code: 'RAB-LOYAUTE', item_name: 'Rabais Fidélité', rate: -40.00, billing: 'recurring', billing_interval: 'Month', contract_months: 24, is_rebate: true, applies_to_item: 'FTTH80I' },
|
||||
{ item_code: 'RAB-LOYAUTE', item_name: 'Rabais promotionnel (24 mois)', rate: -40.00, billing: 'recurring', billing_interval: 'Month', contract_months: 24, is_rebate: true, applies_to_item: 'FTTH80I' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tier_m150',
|
||||
code: 'FTTH150I',
|
||||
label: 'Megafibre 150',
|
||||
speed: '150 / 150 Mbps',
|
||||
price_effective: 99.95,
|
||||
price_base: 99.95,
|
||||
id: 'tier_500',
|
||||
code: 'INT-500',
|
||||
label: 'Fibre 500',
|
||||
speed: '500 / 500 Mbps',
|
||||
price_effective: 49.95,
|
||||
price_base: 89.95,
|
||||
desc: 'Fibre symétrique · streaming 4K, télétravail',
|
||||
icon: 'wifi',
|
||||
items: [
|
||||
{ item_code: 'FTTH150I', item_name: 'Internet Megafibre 150 Mbps', rate: 99.95, billing: 'recurring', billing_interval: 'Month', contract_months: 24, project_template_id: 'fiber_install' },
|
||||
{ item_code: 'INT-500', item_name: 'Internet 500 Mbps', rate: 89.95, billing: 'recurring', billing_interval: 'Month', contract_months: 24, project_template_id: 'fiber_install' },
|
||||
{ item_code: 'RAB-LOYAUTE', item_name: 'Rabais promotionnel (24 mois)', rate: -40.00, billing: 'recurring', billing_interval: 'Month', contract_months: 24, is_rebate: true, applies_to_item: 'INT-500' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tier_g1500',
|
||||
code: 'FTTH1500I',
|
||||
label: 'Gigafibre 1500',
|
||||
speed: '1500 / 940 Mbps',
|
||||
id: 'tier_1g',
|
||||
code: 'INT-1G',
|
||||
label: 'Gigafibre 1 Gbps',
|
||||
speed: '1000 / 1000 Mbps',
|
||||
badge: 'Populaire',
|
||||
price_effective: 109.95,
|
||||
price_effective: 49.95,
|
||||
price_base: 109.95,
|
||||
desc: 'Ultra-rapide · foyers connectés, gaming, cloud',
|
||||
desc: 'Même prix que le 500 — le choix évident',
|
||||
icon: 'speed',
|
||||
default: true,
|
||||
items: [
|
||||
{ item_code: 'FTTH1500I', item_name: 'Internet Gigafibre 1500 Mbps', rate: 109.95, billing: 'recurring', billing_interval: 'Month', contract_months: 24, project_template_id: 'fiber_install' },
|
||||
{ item_code: 'INT-1G', item_name: 'Internet 1 Gbps', rate: 109.95, billing: 'recurring', billing_interval: 'Month', contract_months: 24, project_template_id: 'fiber_install' },
|
||||
{ item_code: 'RAB-LOYAUTE', item_name: 'Rabais promotionnel (24 mois)', rate: -60.00, billing: 'recurring', billing_interval: 'Month', contract_months: 24, is_rebate: true, applies_to_item: 'INT-1G' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -130,25 +130,24 @@
|
|||
|
||||
<!-- FAB messagerie : clic « forum » = ouvrir les communications (action directe et fiable) ; bouton « + » (clic) = menu créer rapide. -->
|
||||
<q-page-sticky v-if="can('view_clients')" position="bottom-right" :offset="[18, 18]">
|
||||
<div class="column items-end q-gutter-sm">
|
||||
<q-slide-transition>
|
||||
<div v-show="quickOpen" class="column items-end q-gutter-xs">
|
||||
<q-btn rounded unelevated color="deep-purple-6" icon="bolt" label="Dicter une commande" no-caps size="sm" @click="quickOpen = false; orchestratorOpen = true" />
|
||||
<q-btn rounded unelevated color="info" icon="wifi_find" label="Statut du service" no-caps size="sm" @click="quickOpen = false; serviceStatusOpen = true" />
|
||||
<q-btn rounded unelevated color="positive" icon="confirmation_number" label="Créer un ticket" no-caps size="sm" @click="quickOpen = false; newTicketOpen = true" />
|
||||
<q-btn rounded unelevated color="green-6" icon="sms" label="Nouveau texto" no-caps size="sm" @click="quickOpen = false; openNew('sms')" />
|
||||
<q-btn rounded unelevated color="negative" icon="mail" label="Nouveau courriel" no-caps size="sm" @click="quickOpen = false; openNew('email')" />
|
||||
</div>
|
||||
</q-slide-transition>
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<q-btn round unelevated :color="quickOpen ? 'blue-grey-7' : 'blue-grey-4'" :icon="quickOpen ? 'close' : 'add'" size="sm" @click="quickOpen = !quickOpen">
|
||||
<q-tooltip>Créer rapidement (ticket · texto · courriel · dicter)</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn fab icon="forum" color="primary" @click="toggleConvPanel">
|
||||
<q-badge v-if="convCount > 0" color="red" floating rounded :label="convCount" />
|
||||
<q-tooltip>Voir les communications</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<!-- Menu « créer rapide » : POPUP composité (q-menu) au lieu d'une colonne à hauteur animée (q-slide-transition) → plus de saccade / reflow du page-sticky. -->
|
||||
<q-btn round unelevated color="blue-grey-5" icon="add" size="sm">
|
||||
<q-tooltip>Créer rapidement (ticket · texto · courriel · dicter)</q-tooltip>
|
||||
<q-menu anchor="top right" self="bottom right" auto-close>
|
||||
<q-list dense style="min-width:212px">
|
||||
<q-item clickable v-close-popup @click="orchestratorOpen = true"><q-item-section avatar><q-icon name="bolt" color="deep-purple-6" /></q-item-section><q-item-section>Dicter une commande</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="serviceStatusOpen = true"><q-item-section avatar><q-icon name="wifi_find" color="info" /></q-item-section><q-item-section>Statut du service</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="goCreate"><q-item-section avatar><q-icon name="add_box" color="positive" /></q-item-section><q-item-section>Créer<q-item-label caption>tâche · intervention · soumission</q-item-label></q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openNew('sms')"><q-item-section avatar><q-icon name="sms" color="green-6" /></q-item-section><q-item-section>Nouveau texto</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openNew('email')"><q-item-section avatar><q-icon name="mail" color="negative" /></q-item-section><q-item-section>Nouveau courriel</q-item-section></q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
<q-btn fab icon="forum" color="primary" @click="openComms">
|
||||
<q-badge v-if="convCount > 0" color="red" floating rounded :label="convCount" />
|
||||
<q-tooltip>Ouvrir les communications (tous les fils · courriel · SMS · chat)</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-page-sticky>
|
||||
|
||||
|
|
@ -187,6 +186,7 @@ const PhoneModal = defineAsyncComponent(() => import('src/components/customer/Ph
|
|||
import { useSoftphone } from 'src/composables/useSoftphone'
|
||||
import NotificationBell from 'src/components/shared/NotificationBell.vue'
|
||||
import { useConversations } from 'src/composables/useConversations'
|
||||
import { useCreateSignal } from 'src/composables/useCreateSignal'
|
||||
const FlowEditorDialog = defineAsyncComponent(() => import('src/components/flow-editor/FlowEditorDialog.vue'))
|
||||
const NewTicketDialog = defineAsyncComponent(() => import('src/components/shared/NewTicketDialog.vue'))
|
||||
const OrchestratorDialog = defineAsyncComponent(() => import('src/components/shared/OrchestratorDialog.vue'))
|
||||
|
|
@ -196,10 +196,14 @@ const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/Out
|
|||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper }
|
||||
|
||||
const { panelOpen, newDialogOpen, newDialogChannel, activeCount: convCount, openComposeDraft } = useConversations()
|
||||
const { requestCreate } = useCreateSignal()
|
||||
const { open: spOpen, number: spNumber, customer: spCustomer, customerName: spCustomerName, provider: spProvider, handleCallEnded: spHandleCallEnded } = useSoftphone()
|
||||
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
|
||||
// FAB « forum » → le MODULE communications complet (page qui défile, tous les fils/SMS/chat) plutôt que le tiroir étroit qui ne scrollait pas.
|
||||
function openComms () { panelOpen.value = false; if (!route.path.startsWith('/communications')) router.push('/communications') }
|
||||
// FAB « Créer » → le chooser unifié de la Planification (tâche · intervention · soumission), où vivent les flux de création.
|
||||
function goCreate () { requestCreate(); if (!route.path.startsWith('/planification')) router.push('/planification') }
|
||||
// FAB messagerie : clic « forum » = ouvrir les communications ; bouton « + » (clic, pas survol) = menu créer rapide.
|
||||
const quickOpen = ref(false)
|
||||
const newTicketOpen = ref(false)
|
||||
const orchestratorOpen = ref(false)
|
||||
const serviceStatusOpen = ref(false)
|
||||
|
|
|
|||
|
|
@ -54,31 +54,20 @@
|
|||
<q-badge v-if="assignPanel.jobs.length" color="red" floating>{{ assignPanel.jobs.length }}</q-badge>
|
||||
<q-tooltip>Afficher / masquer les jobs à répartir (liste · carte · lasso · suggestion auto)</q-tooltip>
|
||||
</q-btn>
|
||||
<!-- Création de job NATIVE OPS (#17) — Dispatch déprécié → tout ici. « Créer » = pool · « Créneau » = choisir tech+date+heure d'abord -->
|
||||
<q-btn dense unelevated no-caps color="teal-7" icon="add_task" label="Créer un job" @click="openCreateJob"><q-tooltip>Créer un job / une installation (apparaît dans « À assigner »)</q-tooltip></q-btn>
|
||||
<q-btn dense outline no-caps color="teal-8" icon="event_available" label="Créneau" @click="showSuggestSlots = true"><q-tooltip>Trouver le meilleur créneau (tech + date + heure) puis créer le job réservé</q-tooltip></q-btn>
|
||||
<!-- Création UNIFIÉE : Tâche · Intervention (sur site → créneau / à distance → ticket support·vente). Remplace « Créer un job » + « Créneau ». -->
|
||||
<q-btn dense unelevated no-caps color="teal-7" icon="add" label="Créer" @click="createChooser = true"><q-tooltip>Créer une tâche ou une intervention (sur site · à distance)</q-tooltip></q-btn>
|
||||
<!-- Menu OUTILS : regroupe la config secondaire pour désencombrer -->
|
||||
<q-btn-dropdown dense outline color="grey-8" icon="build" label="Outils" no-caps>
|
||||
<q-list dense style="min-width:230px">
|
||||
<q-item clickable v-close-popup @click="showDemand = !showDemand">
|
||||
<q-item-section avatar><q-icon name="tune" :color="showDemand ? 'indigo' : 'grey-7'" /></q-item-section>
|
||||
<q-item-section>Demande de personnel</q-item-section>
|
||||
<q-item-section side><q-icon v-if="showDemand" name="check" color="indigo" /></q-item-section>
|
||||
</q-item>
|
||||
<q-list dense style="min-width:250px">
|
||||
<!-- ⚙️ RÉGLAGES : config de planification (dépôt, compétences, cadence, durées, modèles, garde) -->
|
||||
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">⚙️ Réglages planification</q-item-label>
|
||||
<q-item clickable v-close-popup @click="openDepotPicker">
|
||||
<q-item-section avatar><q-icon name="warehouse" color="blue-grey-7" /></q-item-section>
|
||||
<q-item-section>Point de départ (dépôt)<q-item-label caption class="ellipsis" style="max-width:210px">{{ depot && depot.address ? depot.address : 'non défini — cliquer pour situer' }}</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showDayJobs = !showDayJobs">
|
||||
<q-item-section avatar><q-icon name="event_note" :color="showDayJobs ? 'indigo' : 'grey-7'" /></q-item-section>
|
||||
<q-item-section>Jobs prévus en en-tête<q-item-label caption>liste par jour — glisser sur un tech</q-item-label></q-item-section>
|
||||
<q-item-section side><q-icon v-if="showDayJobs" name="check" color="indigo" /></q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showLegacyLoad = !showLegacyLoad">
|
||||
<q-item-section avatar><q-icon name="assignment_ind" :color="showLegacyLoad ? 'brown' : 'grey-7'" /></q-item-section>
|
||||
<q-item-section>Jobs Legacy sur les timelines<q-item-label caption>durées estimées des jobs osTicket datés (fenêtre affichée)</q-item-label></q-item-section>
|
||||
<q-item-section side><q-icon v-if="showLegacyLoad" name="check" color="brown" /></q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showTagManager = true"><q-item-section avatar><q-icon name="sell" color="teal" /></q-item-section><q-item-section>Gérer les compétences (tags)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="speed" /></q-item-section><q-item-section>Cadence équipe</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openJobChar"><q-item-section avatar><q-icon name="timer" color="warning" /></q-item-section><q-item-section>Durées par caractéristique</q-item-section></q-item>
|
||||
<q-item clickable>
|
||||
<q-item-section avatar><q-icon name="bookmark" color="brown" /></q-item-section>
|
||||
<q-item-section>Modèles de semaine</q-item-section>
|
||||
|
|
@ -96,16 +85,33 @@
|
|||
</q-list>
|
||||
</q-menu>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="openGarde"><q-item-section avatar><q-icon name="shield" color="brown" /></q-item-section><q-item-section>Rotation de garde</q-item-section></q-item>
|
||||
<q-separator class="q-my-xs" />
|
||||
<!-- 🗓 HORAIRES & PERSONNEL : génération de quarts, absences, synchro -->
|
||||
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">🗓 Horaires & personnel</q-item-label>
|
||||
<q-item clickable v-close-popup @click="doGenerate"><q-item-section avatar><q-icon name="auto_awesome" color="indigo" /></q-item-section><q-item-section>Générer l'horaire (semaine)<q-item-label caption>solveur de quarts — la semaine affichée</q-item-label></q-item-section><q-item-section side><q-spinner v-if="generating" size="16px" color="indigo" /></q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)<q-item-label caption>modèle → plusieurs techs, N semaines</q-item-label></q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openGarde"><q-item-section avatar><q-icon name="shield" color="brown" /></q-item-section><q-item-section>Rotation de garde</q-item-section></q-item>
|
||||
<q-separator />
|
||||
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="speed" /></q-item-section><q-item-section>Cadence équipe</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="showTagManager = true"><q-item-section avatar><q-icon name="sell" color="teal" /></q-item-section><q-item-section>Gérer les compétences (tags)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openTechSync"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openJobChar"><q-item-section avatar><q-icon name="timer" color="warning" /></q-item-section><q-item-section>Durées par caractéristique</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openAssignPanel"><q-item-section avatar><q-icon name="drag_indicator" color="deep-purple" /></q-item-section><q-item-section>Jobs à assigner (glisser-déposer)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openTechSync"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item>
|
||||
<q-separator class="q-my-xs" />
|
||||
<!-- 👁 AFFICHAGE : bascules de ce qui s'affiche sur le board -->
|
||||
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">👁 Affichage</q-item-label>
|
||||
<q-item clickable v-close-popup @click="openAssignPanel"><q-item-section avatar><q-icon name="drag_indicator" color="deep-purple" /></q-item-section><q-item-section>Jobs à assigner (glisser-déposer)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="showDayJobs = !showDayJobs">
|
||||
<q-item-section avatar><q-icon name="event_note" :color="showDayJobs ? 'indigo' : 'grey-7'" /></q-item-section>
|
||||
<q-item-section>Jobs prévus en en-tête<q-item-label caption>liste par jour — glisser sur un tech</q-item-label></q-item-section>
|
||||
<q-item-section side><q-icon v-if="showDayJobs" name="check" color="indigo" /></q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showLegacyLoad = !showLegacyLoad">
|
||||
<q-item-section avatar><q-icon name="assignment_ind" :color="showLegacyLoad ? 'brown' : 'grey-7'" /></q-item-section>
|
||||
<q-item-section>Jobs Legacy sur les timelines<q-item-label caption>durées estimées des jobs osTicket datés (fenêtre affichée)</q-item-label></q-item-section>
|
||||
<q-item-section side><q-icon v-if="showLegacyLoad" name="check" color="brown" /></q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showDemand = !showDemand">
|
||||
<q-item-section avatar><q-icon name="tune" :color="showDemand ? 'indigo' : 'grey-7'" /></q-item-section>
|
||||
<q-item-section>Demande de personnel<q-item-label caption>panneau besoins vs capacité</q-item-label></q-item-section>
|
||||
<q-item-section side><q-icon v-if="showDemand" name="check" color="indigo" /></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
<q-btn v-if="defaultTemplate" dense flat color="warning" icon="star" :label="defaultTemplate.name" @click="applyDefault"><q-tooltip>Appliquer le modèle par défaut (consciente des absences)</q-tooltip></q-btn>
|
||||
|
|
@ -432,7 +438,7 @@
|
|||
<q-btn flat round dense size="9px" :icon="isPaused(t) ? 'play_arrow' : 'pause'" :color="isPaused(t) ? 'grey' : 'primary'" @click.stop="togglePause(t)"><q-tooltip>{{ isPaused(t) ? 'Réactiver' : 'Pause' }}</q-tooltip></q-btn>
|
||||
<q-badge v-if="techRank(t)" color="teal">{{ techRank(t) }}<q-tooltip>Priorité {{ techRank(t) }} — combine maîtrise (niveau) + vitesse (efficacité) + coût.</q-tooltip></q-badge>
|
||||
<!-- Clic sur la RESSOURCE (nom ou chips) → propriétés de l'employé (compétences, horaire) -->
|
||||
<span class="tech-name clk" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Compétences & horaire de {{ t.name }}</q-tooltip>{{ t.name }}</span>
|
||||
<span class="tech-name clk" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Réglages de {{ t.name }} — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</q-tooltip>{{ t.name }}</span>
|
||||
<span v-if="t.group" class="grp">{{ t.group }}</span>
|
||||
<span v-if="hoursOf(t.id)" class="th" :class="hoursOf(t.id) > maxHours ? 'text-red text-weight-bold' : 'text-grey-6'">{{ hoursOf(t.id) }}h<q-icon v-if="hoursOf(t.id) > maxHours" name="warning" color="red" size="12px" /></span>
|
||||
<q-btn flat dense round size="9px" icon="more_vert" color="grey-6" @click.stop><q-tooltip>Actions — {{ t.name }}</q-tooltip>
|
||||
|
|
@ -562,7 +568,7 @@
|
|||
<!-- Trait « maintenant » CONTINU : 1 seul élément sur toute la hauteur des lanes -->
|
||||
<div v-if="nowLineStyle" class="kbb-now-full" :style="nowLineStyle"></div>
|
||||
<div v-for="t in visibleTechs" :key="t.id" class="kbb-row">
|
||||
<div class="kbb-name"><span class="ellipsis">{{ t.name }}</span><q-icon v-if="kanbanDay && cellOcc(t.id, kanbanDay.iso) && cellOcc(t.id, kanbanDay.iso).over" name="warning" color="warning" size="15px"><q-tooltip class="bg-grey-9" style="font-size:11.5px; max-width:260px"><b>⚠ Surcharge</b><br>{{ overWhy(t.id, kanbanDay.iso) }}</q-tooltip></q-icon></div>
|
||||
<div class="kbb-name"><span class="ellipsis clk" style="cursor:pointer" @click.stop="openSkillEditor(t, $event)">{{ t.name }}<q-tooltip>Réglages de {{ t.name }} — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</q-tooltip></span><q-icon v-if="kanbanDay && cellOcc(t.id, kanbanDay.iso) && cellOcc(t.id, kanbanDay.iso).over" name="warning" color="warning" size="15px"><q-tooltip class="bg-grey-9" style="font-size:11.5px; max-width:260px"><b>⚠ Surcharge</b><br>{{ overWhy(t.id, kanbanDay.iso) }}</q-tooltip></q-icon><q-icon name="tune" size="14px" color="grey-5" class="cursor-pointer" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Réglages du tech (compétences · GPS · domicile · tournée)</q-tooltip></q-icon></div>
|
||||
<div class="kbb-lane" :style="kbLaneStyle" :class="{ 'drop-hover': kanbanDay && dropCell === t.id + '|' + kanbanDay.iso }"
|
||||
@dragover.prevent="kanbanDay && onCellDragOver(t, kanbanDay)" @dragleave="dropCell = null" @drop="kanbanDay && onKbDrop($event, t)"
|
||||
@click.self="kanbanDay && openDayFromCell(t, kanbanDay)" @contextmenu.self.prevent="onKbLaneMenu(t, $event)">
|
||||
|
|
@ -782,7 +788,7 @@
|
|||
<q-menu v-model="skillMenuShown" :target="skillMenuTarget" anchor="bottom left" self="top left" no-focus max-height="80vh">
|
||||
<div v-if="skillDialog" class="q-pa-sm" style="width:368px;min-height:300px" @click.stop @mousedown.stop>
|
||||
<div class="row items-center q-mb-xs"><div class="text-subtitle2 text-weight-bold col ellipsis">🏷 {{ skillDialog.name }}</div><q-btn flat dense round size="sm" icon="close" v-close-popup /></div>
|
||||
<TagEditor :model-value="skillDialog.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" sortable autofocus placeholder="Chercher ou créer une compétence…"
|
||||
<TagEditor :model-value="skillDialog.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" sortable placeholder="Cliquez pour ajouter une compétence…"
|
||||
@update:model-value="items => onTagsChange(skillDialog, items)"
|
||||
@create="onCreateRosterTag" />
|
||||
<div v-if="(skillDialog.skills || []).length > 1" class="text-caption text-grey-6 q-mt-xs" style="line-height:1.3">↔ <b>Glisse les chips</b> pour l'ordre de priorité — la <b>1<sup>re</sup> (★)</b> = fonction principale : le dispatch auto envoie ces jobs à ce tech <b>en premier</b> (épargne les moins spécialisés).</div>
|
||||
|
|
@ -790,7 +796,7 @@
|
|||
<div class="row items-center text-caption text-grey-6 q-pb-xs">
|
||||
<div class="col">Compétence</div>
|
||||
<div style="width:90px" class="text-center">Score</div>
|
||||
<div style="width:88px" class="text-center">Efficacité</div>
|
||||
<div style="width:88px" class="text-center">Cadence</div>
|
||||
</div>
|
||||
<div v-for="(sk, si) in skillDialog.skills" :key="sk" class="row items-center no-wrap q-py-xs" style="border-top:1px solid #eee">
|
||||
<div class="col row items-center no-wrap">
|
||||
|
|
@ -813,15 +819,16 @@
|
|||
<q-icon v-for="n in 5" :key="n" :name="skillLevelOf(skillDialog, sk) >= n ? 'star' : 'star_outline'" :color="skillLevelOf(skillDialog, sk) >= n ? 'indigo' : 'grey-4'" size="16px" class="cursor-pointer" @click="setSkillLevel(skillDialog, sk, skillLevelOf(skillDialog, sk) === n ? 0 : n)" />
|
||||
</div>
|
||||
<div style="width:88px">
|
||||
<q-input dense outlined type="number" step="5" debounce="600" :model-value="skillEffPct(skillDialog, sk)" @update:model-value="v => setSkillEffPct(skillDialog, sk, v)" suffix="%" placeholder="glob." input-class="text-right" />
|
||||
<q-input dense outlined type="number" step="25" min="20" max="1000" :model-value="(effBufKey(skillDialog, sk) in effBuf) ? effBuf[effBufKey(skillDialog, sk)] : skillEffPct(skillDialog, sk)" @update:model-value="v => { effBuf[effBufKey(skillDialog, sk)] = v }" @blur="commitSkillEff(skillDialog, sk)" @keyup.enter="commitSkillEff(skillDialog, sk)" suffix="%" placeholder="glob." input-class="text-right" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-caption text-grey-6 q-mt-xs"><b>Score</b> ★ = maîtrise · <b>Efficacité</b> : <b>+</b> plus vite / <b>−</b> plus lent pour CETTE compétence (ex. <b>+80</b>), vide = globale · × sur le chip = retirer.</div>
|
||||
<div class="text-caption text-grey-6 q-mt-xs"><b>Score</b> ★ = maîtrise · <b>Cadence</b> : <b>100 %</b> = normale · <b>200 %</b> = deux fois plus de jobs · < 100 % = plus lent · vide = hérite du global · × sur le chip = retirer.</div>
|
||||
<div class="text-caption text-green-7 q-mt-xs row items-center no-wrap" style="gap:3px"><q-icon name="cloud_done" size="13px" />Enregistré automatiquement — pas de bouton à cliquer.</div>
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire</div>
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<q-btn dense unelevated size="sm" no-caps color="primary" icon="event_note" label="Générer l'horaire…" @click="openSchedGen(skillDialog)"><q-tooltip>Modèles (5×8h · 4×10h · 3×12h) + heures par jour + N semaines → publie les quarts</q-tooltip></q-btn>
|
||||
<q-btn dense outline size="sm" no-caps color="indigo-7" icon="event_note" label="Générer l'horaire…" @click="openSchedGen(skillDialog)"><q-tooltip>Ouvre l'assistant de quarts (modèles 5×8h · 4×10h · 3×12h + heures/jour + N semaines) → publie les quarts. Ce n'est PAS un bouton « enregistrer ».</q-tooltip></q-btn>
|
||||
<q-btn dense outline size="sm" no-caps color="primary" label="5×8h rapide" @click="applyWeekPreset(skillDialog, [1,2,3,4,5], 8, 16)"><q-tooltip>Applique 5×8h à la semaine affichée (à publier)</q-tooltip></q-btn>
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
|
|
@ -830,6 +837,30 @@
|
|||
<q-btn dense outline size="sm" no-caps color="teal" :icon="techHomes[skillDialog.id] ? 'edit_location_alt' : 'add_location_alt'" :label="techHomes[skillDialog.id] ? 'Modifier' : 'Définir'" @click="openHomePicker(skillDialog)" />
|
||||
<span class="text-caption text-grey-6 ellipsis col">{{ techHomes[skillDialog.id] && techHomes[skillDialog.id].address ? techHomes[skillDialog.id].address : 'non défini' }}</span>
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<!-- 📍 Appareil GPS (Traccar) — même contrôle que l'éditeur de jour, ici accessible directement depuis le tech -->
|
||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">📍 Appareil GPS <span class="text-grey-5 text-weight-regular">(Traccar — suivi live + tracé réel)</span></div>
|
||||
<div class="row items-center no-wrap q-gutter-xs">
|
||||
<template v-if="deviceOf(skillDialog)">
|
||||
<q-chip dense :color="deviceOf(skillDialog).missing ? 'orange-1' : 'green-1'" :text-color="deviceOf(skillDialog).missing ? 'orange-9' : 'green-9'" icon="place" class="text-weight-medium">
|
||||
<q-icon name="circle" size="8px" :color="deviceOf(skillDialog).online ? 'positive' : 'grey-5'" class="q-mr-xs" />{{ deviceOf(skillDialog).label }}
|
||||
<q-tooltip>{{ deviceOf(skillDialog).online ? 'En ligne' : 'Hors ligne' }}{{ deviceOf(skillDialog).uniqueId ? ' · ' + deviceOf(skillDialog).uniqueId : '' }}</q-tooltip>
|
||||
</q-chip>
|
||||
<q-btn flat dense no-caps size="sm" color="indigo" icon="swap_horiz" label="Changer"><q-tooltip>Relier à un autre véhicule</q-tooltip>
|
||||
<q-menu anchor="bottom left" self="top left"><div class="q-pa-xs" style="min-width:250px"><q-input dense outlined v-model="deviceFilter" placeholder="Filtrer les véhicules…" clearable autofocus class="q-mb-xs" /><q-list dense style="max-height:40vh;overflow:auto"><q-item v-for="o in deviceOptions" :key="o.value" clickable v-close-popup @click="onPickDevice(skillDialog, o.value)" :active="deviceOf(skillDialog) && deviceOf(skillDialog).value === o.value" active-class="bg-green-1"><q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="o.online ? 'positive' : 'grey-4'" /></q-item-section><q-item-section><q-item-label>{{ o.label }}</q-item-label><q-item-label v-if="o.uniqueId" caption>{{ o.uniqueId }}</q-item-label></q-item-section></q-item><q-item v-if="!deviceOptions.length"><q-item-section class="text-grey-6">{{ devicesLoading ? 'Chargement…' : 'Aucun appareil' }}</q-item-section></q-item></q-list></div></q-menu>
|
||||
</q-btn>
|
||||
<q-btn flat dense round size="sm" icon="link_off" color="grey-6" @click="onPickDevice(skillDialog, null)"><q-tooltip>Dissocier l'appareil GPS</q-tooltip></q-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-caption text-grey-6">non associé</span>
|
||||
<q-btn v-if="deviceSuggestOf(skillDialog)" dense unelevated size="sm" no-caps color="primary" icon="link" :label="'Associer ' + deviceSuggestOf(skillDialog).label" @click="onPickDevice(skillDialog, deviceSuggestOf(skillDialog).value)"><q-tooltip>Appareil probable (nom similaire au tech)</q-tooltip></q-btn>
|
||||
<q-btn flat dense no-caps size="sm" color="indigo" icon="add_location_alt" label="Choisir"><q-tooltip>Relier un véhicule Traccar</q-tooltip>
|
||||
<q-menu anchor="bottom left" self="top left"><div class="q-pa-xs" style="min-width:250px"><q-input dense outlined v-model="deviceFilter" placeholder="Filtrer les véhicules…" clearable autofocus class="q-mb-xs" /><q-list dense style="max-height:40vh;overflow:auto"><q-item v-for="o in deviceOptions" :key="o.value" clickable v-close-popup @click="onPickDevice(skillDialog, o.value)"><q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="o.online ? 'positive' : 'grey-4'" /></q-item-section><q-item-section><q-item-label>{{ o.label }}</q-item-label><q-item-label v-if="o.uniqueId" caption>{{ o.uniqueId }}</q-item-label></q-item-section></q-item><q-item v-if="!deviceOptions.length"><q-item-section class="text-grey-6">{{ devicesLoading ? 'Chargement…' : 'Aucun appareil' }}</q-item-section></q-item></q-list></div></q-menu>
|
||||
</q-btn>
|
||||
</template>
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<q-btn dense unelevated size="sm" no-caps color="deep-purple" icon="route" label="Voir sa tournée" class="full-width" @click="viewTechTournee(skillDialog)"><q-tooltip>Passe en vue Tournées et isole la tournée de ce tech sur la carte</q-tooltip></q-btn>
|
||||
</div>
|
||||
</q-menu>
|
||||
|
||||
|
|
@ -1094,7 +1125,7 @@
|
|||
<q-icon v-if="(Number(t.efficiency) || 1) < 0.95" name="bolt" size="14px" color="green-6"><q-tooltip>Rapide (efficacité {{ t.efficiency }})</q-tooltip></q-icon>
|
||||
<q-icon v-else-if="(Number(t.efficiency) || 1) > 1.05" name="hourglass_bottom" size="13px" color="orange-6"><q-tooltip>Plus lent (efficacité {{ t.efficiency }})</q-tooltip></q-icon>
|
||||
<q-icon v-if="!(dayList || []).some(d => hasShiftDay(t.id, d.iso))" name="event_busy" size="13px" color="amber-7"><q-tooltip>Aucun quart publié cette période</q-tooltip></q-icon>
|
||||
<q-icon name="edit" size="14px" color="grey-6" class="cursor-pointer" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Modifier compétences & niveaux (★) + efficacité</q-tooltip></q-icon>
|
||||
<q-icon name="tune" size="14px" color="grey-6" class="cursor-pointer" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Réglages du tech — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</q-tooltip></q-icon>
|
||||
</div>
|
||||
</div>
|
||||
<q-toggle v-model="suggestDlg.makeShifts" dense size="sm" color="primary" class="q-mt-sm" label="Créer un quart 8–16 pour les techs sans quart (à l'application)" />
|
||||
|
|
@ -1453,16 +1484,17 @@
|
|||
<q-btn dense unelevated color="deep-purple" icon="group_add" label="Ajouter" :disable="!jobDetail.teamAdd" @click="jdAddAssistant" no-caps />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="jobDetail.detail" class="jd-detail">{{ jobDetail.detail }}</div>
|
||||
<div class="text-subtitle2 q-mt-md q-mb-xs">Commentaires / fil du billet</div>
|
||||
<div class="text-subtitle2 q-mt-md q-mb-xs row items-center">Commentaires / fil du billet<span v-if="jdMessages.length" class="text-grey-5 q-ml-sm" style="font-size:10px">· plus récent en haut</span></div>
|
||||
<div v-if="jobDetail.loading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /><div class="text-caption text-grey-6 q-mt-sm">Lecture du ticket…</div></div>
|
||||
<template v-else-if="jobDetail.thread && jobDetail.thread.messages && jobDetail.thread.messages.length">
|
||||
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ jobDetail.thread.messages.length }} message(s){{ jobDetail.thread.status ? ' · ' + jobDetail.thread.status : '' }}</div>
|
||||
<div v-for="(m, mi) in jobDetail.thread.messages" :key="mi" class="de-msg">
|
||||
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span></div>
|
||||
<template v-else-if="jdMessages.length">
|
||||
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ jdMessages.length }} message(s){{ jobDetail.thread.status ? ' · ' + jobDetail.thread.status : '' }}</div>
|
||||
<div v-for="(m, mi) in jdMessages" :key="mi" class="de-msg" :class="{ 'de-msg-latest': mi === 0 }">
|
||||
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span><span v-if="mi === 0 && jdMessages.length > 1" class="de-msg-badge">dernier</span></div>
|
||||
<div class="de-msg-txt">{{ m.text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Repli : corps brut du billet UNIQUEMENT s'il n'y a aucun message dans le fil (sinon = doublon du 1er commentaire) -->
|
||||
<div v-else-if="jobDetail.detail" class="jd-detail">{{ jobDetail.detail }}</div>
|
||||
<div v-else class="text-grey-6 q-pa-md text-center">{{ jobDetail.lid ? ((jobDetail.thread && jobDetail.thread.error) ? 'Détail indisponible' : 'Aucun commentaire.') : 'Pas de billet Legacy lié à ce job.' }}</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
|
@ -1848,10 +1880,36 @@
|
|||
</q-dialog>
|
||||
|
||||
<!-- #17 — Création de job NATIVE OPS (work-order) : réutilise le modal partagé. @created → rafraîchit le pool. -->
|
||||
<UnifiedCreateModal v-model="createJobOpen" mode="work-order" :context="createJobCtx"
|
||||
<UnifiedCreateModal v-model="createJobOpen" :mode="createMode" :context="createJobCtx"
|
||||
:technicians="techs" :external-tags="tagCatalog" :external-get-color="getTagColor" @created="onJobCreated" />
|
||||
<!-- Chooser « Créer » : Tâche · Intervention (sur site → créneau / à distance → ticket) — un seul point d'entrée -->
|
||||
<q-dialog v-model="createChooser">
|
||||
<q-card style="min-width:340px;max-width:440px">
|
||||
<q-card-section class="row items-center q-pb-sm"><div class="text-h6">Créer…</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section>
|
||||
<q-card-section class="q-pt-none column q-gutter-sm">
|
||||
<q-btn no-caps unelevated align="left" color="grey-2" text-color="grey-9" class="cc-opt" @click="startCreate('task')">
|
||||
<q-icon name="task_alt" color="indigo" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">Tâche</div><div class="text-caption text-grey-7">Travail interne — sans client ni déplacement</div></div>
|
||||
</q-btn>
|
||||
<div class="text-caption text-weight-medium text-grey-7 q-mt-xs">Intervention</div>
|
||||
<q-btn no-caps unelevated align="left" color="teal-1" text-color="teal-10" class="cc-opt" @click="startCreneau">
|
||||
<q-icon name="construction" color="teal-8" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">Sur site — trouver un créneau</div><div class="text-caption text-grey-7">Meilleur tech + date + heure (selon priorité & compétences)</div></div>
|
||||
</q-btn>
|
||||
<q-btn no-caps flat dense align="left" color="teal-9" class="cc-sub" @click="startCreate('work-order')"><q-icon name="inbox" size="16px" class="q-mr-sm" />…ou ajouter au pool (planifier plus tard)</q-btn>
|
||||
<q-btn no-caps unelevated align="left" color="blue-1" text-color="blue-10" class="cc-opt" @click="startCreate('ticket')">
|
||||
<q-icon name="headset_mic" color="blue-8" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">À distance — ticket</div><div class="text-caption text-grey-7">Support ou vente (selon priorité & compétences), sans déplacement</div></div>
|
||||
</q-btn>
|
||||
<q-separator class="q-my-xs" />
|
||||
<q-btn no-caps unelevated align="left" color="amber-1" text-color="amber-10" class="cc-opt" @click="createChooser = false; quoteWizardOpen = true">
|
||||
<q-icon name="request_quote" color="amber-8" size="24px" class="q-mr-md" /><div class="text-left"><div class="text-weight-medium">Soumission</div><div class="text-caption text-grey-7">Vérifier l'adresse (fibre) → prospect → services & devis — même pour un non-client</div></div>
|
||||
</q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- « Trouver un créneau » → chips compétence (durée par défaut) + adresse → choisit tech+date+heure puis pré-remplit la création -->
|
||||
<SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" @plan-shifts="onPlanShifts" />
|
||||
<!-- Soumission : wizard adresse+fibre+prospect → passe le relais à ProjectWizard (panier + rabais + devis + acceptation) -->
|
||||
<QuoteWizard v-model="quoteWizardOpen" @qualified="onQuoteQualified" />
|
||||
<ProjectWizard v-model="projectWizardOpen" :customer="quoteCustomer" @created="onQuoteCreated" />
|
||||
<!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) → écrit les Shift Assignment -->
|
||||
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
|
||||
</q-page>
|
||||
|
|
@ -1885,7 +1943,7 @@ import { useQuasar } from 'quasar'
|
|||
import * as roster from 'src/api/roster'
|
||||
import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement
|
||||
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
|
||||
import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch
|
||||
import { installMaplibre, basemapStyle, createPlanMap } from 'src/config/basemap' // fond de carte MapLibre/OSM auto-hébergé (plus de Mapbox, plus de logo) + init commune des cartes
|
||||
import { legacyDeptColor, heatColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé) + heatColor (source unique, aussi <OccupancyStrip>)
|
||||
import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur)
|
||||
import { relTime, initials } from 'src/composables/useFormatters' // temps relatif + initiales (source unique, ex-locales dé-dupliquées)
|
||||
|
|
@ -1899,6 +1957,9 @@ import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées
|
|||
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord)
|
||||
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) — Dispatch déprécié, tout ici
|
||||
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » → pré-remplit la création (tech+date+heure+adresse)
|
||||
import QuoteWizard from 'src/components/shared/QuoteWizard.vue' // soumission : adresse+fibre+prospect (lead-first)
|
||||
import { useCreateSignal } from 'src/composables/useCreateSignal' // FAB global « Créer » → ouvre le chooser ici
|
||||
import ProjectWizard from 'src/components/shared/ProjectWizard.vue' // moteur soumission existant (panier + rabais + devis + acceptation) — réutilisé
|
||||
import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) — repris/amélioré depuis Dispatch
|
||||
|
||||
const $q = useQuasar()
|
||||
|
|
@ -2150,9 +2211,9 @@ function kanbanLaneBlocks (techId) {
|
|||
}
|
||||
return out
|
||||
}
|
||||
// Matrice Mapbox RÉELLE par tournée (tech×jour) : 1 SEUL appel /tournée (≤25 coords) → temps de route réels pour tous les legs. Cache par signature (set de jobs) → pas de refetch inutile.
|
||||
// Matrice OSRM RÉELLE par tournée (tech×jour) : 1 SEUL appel /tournée (≤25 coords) → temps de route réels pour tous les legs. Cache par signature (set de jobs) → pas de refetch inutile.
|
||||
async function fetchKbMatrix (techId) {
|
||||
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso || !MAPBOX_TOKEN) return
|
||||
const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return
|
||||
const key = techId + '|' + iso
|
||||
const o = occByTechDay.value[key]
|
||||
const jobs = (o && o.jobs ? o.jobs : []).filter(j => isFinite(+j.lat) && isFinite(+j.lon) && (+j.lat || +j.lon))
|
||||
|
|
@ -2180,7 +2241,32 @@ async function reloadPool () { try { assignPanel.jobs = capPickups((await roster
|
|||
const createJobOpen = ref(false)
|
||||
const createJobCtx = ref({})
|
||||
const showSuggestSlots = ref(false)
|
||||
function openCreateJob () { createJobCtx.value = { scheduled_date: '' }; createJobOpen.value = true } // pool (non planifié) par défaut
|
||||
const createChooser = ref(false) // chooser « Créer » : Tâche / Intervention (sur site / à distance)
|
||||
const createMode = ref('work-order') // mode passé à UnifiedCreateModal : 'task' | 'work-order' | 'ticket'
|
||||
function openCreateJob () { createMode.value = 'work-order'; createJobCtx.value = { scheduled_date: '' }; createJobOpen.value = true } // pool (non planifié) par défaut
|
||||
// Point d'entrée unifié « Créer ». startCreate ouvre le formulaire dans le bon mode ; startCreneau ouvre plutôt le solveur de créneau (sur site).
|
||||
function startCreate (mode) { createMode.value = mode; createJobCtx.value = { scheduled_date: '' }; createChooser.value = false; createJobOpen.value = true }
|
||||
function startCreneau () { createMode.value = 'work-order'; createChooser.value = false; showSuggestSlots.value = true }
|
||||
// Soumission : QuoteWizard qualifie (adresse+fibre+prospect) → on ouvre ProjectWizard pré-rempli avec le client.
|
||||
const quoteWizardOpen = ref(false)
|
||||
const projectWizardOpen = ref(false)
|
||||
const quoteCustomer = ref(null)
|
||||
function onQuoteQualified (payload) {
|
||||
const cust = (payload && payload.customer) || null
|
||||
// Rattache l'adresse qualifiée (+ zone tarifaire) au client passé à ProjectWizard → portée sur le devis (le prix dépend de l'adresse).
|
||||
quoteCustomer.value = cust ? { ...cust, qualified_address: (payload && payload.address) || null } : null
|
||||
if (quoteCustomer.value) projectWizardOpen.value = true
|
||||
}
|
||||
function onQuoteCreated (job, meta) {
|
||||
projectWizardOpen.value = false
|
||||
// Pas de contrat signé requis (JWT/DocuSeal) → on enchaîne direct sur « Trouver un créneau » d'installation. Sinon : attendre l'approbation.
|
||||
if (meta && !meta.needsAcceptance) {
|
||||
$q.dialog({ title: 'Soumission créée', message: 'Planifier l\'installation maintenant (trouver un créneau) ?', cancel: 'Plus tard', ok: { label: 'Trouver un créneau', color: 'teal-8', noCaps: true }, persistent: false })
|
||||
.onOk(() => { showSuggestSlots.value = true })
|
||||
} else {
|
||||
$q.notify({ type: 'positive', icon: 'request_quote', message: 'Soumission créée — en attente d\'approbation client avant de planifier', timeout: 3200 })
|
||||
}
|
||||
}
|
||||
function onSlotSelected (slot) { // un créneau choisi → pré-remplit tech+date+heure+adresse+coords+compétence
|
||||
createJobCtx.value = { assigned_tech: slot.tech_id, scheduled_date: slot.date, start_time: slot.start_time, address: slot._address || '', duration_h: slot._duration || 1, _latitude: slot._latitude, _longitude: slot._longitude, tags: slot._skill ? [slot._skill] : [] }
|
||||
createJobOpen.value = true
|
||||
|
|
@ -2297,6 +2383,8 @@ const jdTeamOptions = computed(() => {
|
|||
.sort((a, b) => (b.capable - a.capable) || a.label.localeCompare(b.label))
|
||||
.map(({ label, value }) => ({ label, value }))
|
||||
})
|
||||
// Fil du billet, PLUS RÉCENT EN HAUT (le dernier commentaire porte souvent l'info la plus importante).
|
||||
const jdMessages = computed(() => { const m = (jobDetail.thread && jobDetail.thread.messages) || []; return m.slice().reverse() })
|
||||
async function jdAddAssistant () {
|
||||
const id = jobDetail.teamAdd; if (!id || !jobDetail.name) return
|
||||
const tech = (techs.value || []).find(t => t.id === id); if (!tech) return
|
||||
|
|
@ -2431,12 +2519,11 @@ function jdNearby () {
|
|||
return out.slice(0, 80)
|
||||
}
|
||||
async function initJdMap () {
|
||||
if (!MAPBOX_TOKEN || !jdMapEl.value) return
|
||||
if (!jdMapEl.value) return
|
||||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !jdMapEl.value) return
|
||||
if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null }
|
||||
const lat = jobDetail.lat, lon = jobDetail.lon; const hasLL = isFinite(lat) && isFinite(lon) && (lat || lon)
|
||||
_jdMap = new mapboxgl.Map({ container: jdMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: hasLL ? [lon, lat] : [-73.6756, 45.1599], zoom: hasLL ? 14 : 9 })
|
||||
_jdMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
|
||||
_jdMap = createPlanMap(jdMapEl.value, { center: hasLL ? [lon, lat] : [-73.6756, 45.1599], zoom: hasLL ? 14 : 9 })
|
||||
_jdMap.on('load', () => {
|
||||
_jdMap.resize()
|
||||
// Autres jobs à proximité = points PÂLES (contexte), ajoutés AVANT le marqueur du job → celui-ci reste au-dessus.
|
||||
|
|
@ -2526,7 +2613,15 @@ const skillByType = ref({}) // type de job → compétence requise déduite (pol
|
|||
const skillDialog = ref(null) // tech dont on édite les compétences
|
||||
const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée)
|
||||
const skillMenuShown = ref(false)
|
||||
function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true }
|
||||
function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true; loadTraccarDevices() } // charge les appareils GPS pour la section « Appareil GPS » du volet
|
||||
// « Voir sa tournée » depuis le volet réglages du tech : bascule en vue Tournées + isole ce tech sur la carte.
|
||||
function viewTechTournee (tech) {
|
||||
if (!tech || !tech.id) return
|
||||
skillMenuShown.value = false
|
||||
if (!routesDay.value) routesDay.value = todayISO()
|
||||
boardView.value = 'routes'
|
||||
nextTick(() => { const others = allDayRoutes.value.map(r => r.id).filter(x => x !== tech.id); hiddenRouteTechs.value = new Set(others) })
|
||||
}
|
||||
function openSkillEditorFor (techId, ev) { const t = (visibleTechs.value || []).find(x => x.id === techId); if (t) openSkillEditor(t, ev) } // depuis la revue de l'optimiseur
|
||||
// TagEditor (showLevel) ↔ tech : skills = libellés (CSV) · skill_levels = {compétence: niveau 1–5} (JSON).
|
||||
// Par compétence : SCORE (maîtrise 1–5, qualité) + EFFICACITÉ (facteur vitesse ; défaut = efficacité globale).
|
||||
|
|
@ -2579,14 +2674,19 @@ async function onScheduleApply ({ schedule, weeks, techIds, mode }) {
|
|||
}
|
||||
function skillEffOf (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? Number(e) : (Number(t.efficiency) || 1) } // facteur (pour le calcul de priorité)
|
||||
// Efficacité saisie en % de PERFORMANCE : + = plus VITE (moins de temps) · − = plus LENT. Conversion ↔ facteur.
|
||||
function effPctOf (factor) { return Math.round((1 - (Number(factor) || 1)) * 100) }
|
||||
function factorFromPct (pct) { const f = 1 - (Number(pct) || 0) / 100; return Math.max(0.1, Math.min(3, Math.round(f * 100) / 100)) }
|
||||
// CADENCE en % : 100 = normal · 200 = deux fois plus de jobs (moitié du temps) · <100 = plus lent. Interne = FACTEUR de durée (200 %→0.5).
|
||||
function effPctOf (factor) { const f = Number(factor) || 1; return Math.round(100 / (f > 0 ? f : 1)) }
|
||||
function factorFromPct (pct) { const p = Number(pct) || 100; const f = 100 / (p > 0 ? p : 100); return Math.max(0.1, Math.min(5, Math.round(f * 100) / 100)) } // cap 20 %–1000 %
|
||||
function skillEffPct (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? effPctOf(e) : '' } // '' = hérite de la globale
|
||||
function setSkillEffPct (t, sk, pct) { const m = { ...(t.skill_eff || {}) }; if (pct === '' || pct == null) delete m[sk]; else m[sk] = factorFromPct(pct); t.skill_eff = m; queueSkillSave(t) } // libre (ex. +80)
|
||||
// Saisie efficacité : tampon local pendant l'édition (pas de debounce qui perdait la valeur au blur) → commit sur blur/Entrée.
|
||||
const effBuf = reactive({})
|
||||
function effBufKey (t, sk) { return (t && t.id) + '::' + sk }
|
||||
function commitSkillEff (t, sk) { const k = effBufKey(t, sk); if (!(k in effBuf)) return; const v = effBuf[k]; delete effBuf[k]; setSkillEffPct(t, sk, v) }
|
||||
function setSkillLevel (t, sk, v) { if (!t.skill_levels) t.skill_levels = {}; t.skill_levels = { ...t.skill_levels, [sk]: v }; queueSkillSave(t) }
|
||||
// Efficacité GLOBALE du tech, éditable/réinitialisable depuis le popover (débouncée via setTechEfficiency).
|
||||
// Couleur du cercle (vitesse PAR compétence) : vite → vert · normal → bleu-gris · lent → rouge. Intensité ∝ écart.
|
||||
function effColor (factor) { const p = effPctOf(factor); if (!p) return '#607d8b'; const a = Math.min(Math.abs(p), 30) / 30; const hue = p > 0 ? 122 : 4; return 'hsl(' + hue + ',' + Math.round(50 + a * 28) + '%,' + (44 - Math.round(a * 8)) + '%)' }
|
||||
function effColor (factor) { const p = effPctOf(factor); if (p === 100) return '#607d8b'; const a = Math.min(Math.abs(p - 100), 200) / 200; const hue = p > 100 ? 122 : 4; return 'hsl(' + hue + ',' + Math.round(50 + a * 28) + '%,' + (44 - Math.round(a * 8)) + '%)' }
|
||||
function skillEffColor (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e == null || e === '') ? '#607d8b' : effColor(Number(e)) } // neutre si pas d'override (pure par-compétence)
|
||||
// Icône Material (offline, jeu Quasar par défaut) représentant une compétence/type — par mots-clés, repli 'bolt'.
|
||||
// Icône de compétence pour q-icon (accepte les SYMBOLES SVG importés) : échelle=installation, casque=support,
|
||||
|
|
@ -2911,13 +3011,13 @@ function groupLabel (j) { return assignLocations.value.byName[j.name] || '' } //
|
|||
// ── Carte des jobs à assigner (Mapbox GL) : 1 pin par adresse, lettre = groupe ; clic = sélectionne les jobs co-localisés ──
|
||||
const assignMapEl = ref(null); let _assignMap = null, _assignMapRO = null
|
||||
|
||||
// ── Bascule vue SATELLITE (couche raster Mapbox superposée SOUS les pins ; pas de setStyle = pins conservés) ──
|
||||
// ── Bascule vue SATELLITE (couche raster ESRI World Imagery — libre, sans jeton — superposée SOUS les pins) ──
|
||||
// Préférence partagée par les 2 cartes (panneau + journée), persistée. addSatLayer() est appelé dans chaque on('load')
|
||||
// AVANT les couches de pins → le raster reste dessous. toggleSat() bascule la visibilité sur les cartes vivantes.
|
||||
const satView = ref((() => { try { return localStorage.getItem('plan_sat') === '1' } catch (e) { return false } })())
|
||||
function addSatLayer (map) {
|
||||
try {
|
||||
if (!map.getSource('sat')) map.addSource('sat', { type: 'raster', url: 'mapbox://mapbox.satellite', tileSize: 256 })
|
||||
if (!map.getSource('sat')) map.addSource('sat', { type: 'raster', tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], tileSize: 256, attribution: '© Esri, Maxar, Earthstar Geographics' })
|
||||
if (!map.getLayer('sat')) map.addLayer({ id: 'sat', type: 'raster', source: 'sat', layout: { visibility: satView.value ? 'visible' : 'none' } })
|
||||
} catch (e) { /* style pas prêt — ignoré */ }
|
||||
}
|
||||
|
|
@ -2963,8 +3063,7 @@ async function setHomeFromLiveGps (t) {
|
|||
try {
|
||||
const r = await roster.techLivePosition(t.id)
|
||||
if (!r || !r.ok || r.lat == null) { $q.notify({ type: 'warning', message: (r && r.error) || 'Aucune position GPS live (appareil Traccar non associé ?)' }); return }
|
||||
let address = ''
|
||||
if (MAPBOX_TOKEN) { try { const g = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${r.lon},${r.lat}.json?access_token=${MAPBOX_TOKEN}&language=fr&country=ca&types=address,poi`); const jj = await g.json(); const f = (jj.features || [])[0]; if (f && f.place_name) address = f.place_name.replace(/, Canada$/, '') } catch (_) { /* géocodage inverse non bloquant */ } }
|
||||
const address = await reverseGeocodeOSM(r.lon, r.lat)
|
||||
const homes = { ...techHomes.value, [t.id]: { address, lat: +(+r.lat).toFixed(6), lon: +(+r.lon).toFixed(6) } }
|
||||
await roster.savePolicy({ tech_homes: homes }); techHomes.value = homes
|
||||
if (_dayMap) refreshDayMap()
|
||||
|
|
@ -2997,20 +3096,20 @@ const deviceOptions = computed(() => {
|
|||
.filter(o => !q || o.label.toLowerCase().includes(q) || (o.uniqueId && o.uniqueId.toLowerCase().includes(q)))
|
||||
.sort((a, b) => (b.online ? 1 : 0) - (a.online ? 1 : 0) || a.label.localeCompare(b.label))
|
||||
})
|
||||
// Appareil ACTUELLEMENT relié au tech de l'éditeur (résout l'id → nom réel, ex. « 24 - Stephane M »). missing = id enregistré mais introuvable dans Traccar.
|
||||
const jdDevice = computed(() => {
|
||||
const id = dayEditor.tech && dayEditor.tech.traccar_device_id
|
||||
// Appareil GPS relié à N'IMPORTE QUEL tech (résout l'id → nom réel). Réutilisé par l'éditeur de jour ET le volet réglages du tech.
|
||||
function deviceOf (tech) {
|
||||
const id = tech && tech.traccar_device_id
|
||||
if (!id) return null
|
||||
const d = traccarDevices.value.find(x => String(x.id) === String(id))
|
||||
if (d) return { value: String(d.id), label: d.name || d.uniqueId || ('#' + d.id), online: d.status === 'online', uniqueId: d.uniqueId || '' }
|
||||
return { value: String(id), label: devicesLoading.value ? ('#' + id + '…') : ('#' + id + ' — introuvable'), online: false, missing: !devicesLoading.value }
|
||||
})
|
||||
}
|
||||
const jdDevice = computed(() => deviceOf(dayEditor.tech))
|
||||
// Suggestion AUTO quand non relié : appareil dont le nom/uniqueId contient un mot du nom du tech (≥3 lettres). Facilite « relier ».
|
||||
const _normDev = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '')
|
||||
const jdDeviceSuggest = computed(() => {
|
||||
if (jdDevice.value) return null
|
||||
const t = dayEditor.tech; if (!t) return null
|
||||
const parts = _normDev(t.name).split(/[^a-z]+/).filter(p => p.length >= 3)
|
||||
function deviceSuggestOf (tech) {
|
||||
if (!tech || deviceOf(tech)) return null
|
||||
const parts = _normDev(tech.name).split(/[^a-z]+/).filter(p => p.length >= 3)
|
||||
if (!parts.length) return null
|
||||
let best = null
|
||||
for (const d of traccarDevices.value) {
|
||||
|
|
@ -3020,7 +3119,8 @@ const jdDeviceSuggest = computed(() => {
|
|||
}
|
||||
if (best && best.hit >= 1) { const d = best.d; return { value: String(d.id), label: d.name || ('#' + d.id), online: d.status === 'online' } }
|
||||
return null
|
||||
})
|
||||
}
|
||||
const jdDeviceSuggest = computed(() => deviceSuggestOf(dayEditor.tech))
|
||||
async function onPickDevice (tech, deviceId) {
|
||||
if (!tech || !tech.id) return
|
||||
const prev = tech.traccar_device_id || ''; const v = deviceId == null ? '' : String(deviceId)
|
||||
|
|
@ -3032,10 +3132,7 @@ async function onPickDevice (tech, deviceId) {
|
|||
async function initLocMap () {
|
||||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !locMapEl.value) return
|
||||
if (_locMap) { _locMap.resize(); return }
|
||||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||||
_locMap = new mapboxgl.Map({ container: locMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [locPicker.lon || -73.95, locPicker.lat || 45.18], zoom: locPicker.lat != null ? 14 : 8.5 })
|
||||
_locMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
|
||||
_locMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
|
||||
_locMap = createPlanMap(locMapEl.value, { center: [locPicker.lon || -73.95, locPicker.lat || 45.18], zoom: locPicker.lat != null ? 14 : 8.5, streetView: openStreetView })
|
||||
_locMap.on('load', () => {
|
||||
_locMap.resize(); addSatLayer(_locMap)
|
||||
if (locPicker.lat == null) _locMap.fitBounds(TERR_BOUNDS, { padding: 18, duration: 0 }) // zoom sur la région au sud de Montréal
|
||||
|
|
@ -3051,14 +3148,19 @@ function placeLocMarker (lon, lat, doReverse) {
|
|||
} else _locMarker.setLngLat([lon, lat])
|
||||
if (doReverse) reverseGeocode(lon, lat)
|
||||
}
|
||||
async function reverseGeocode (lon, lat) {
|
||||
if (!MAPBOX_TOKEN) return
|
||||
// Géocodage INVERSE (coord → adresse). 1) NOTRE RQA locale (hub /address/reverse — pas de dépendance externe). 2) repli OSM/Nominatim.
|
||||
async function reverseGeocodeOSM (lon, lat) {
|
||||
try { const r = await addressApi.reverse(lat, lon); if (r && r.found && r.address) return r.address } catch (e) { /* RQA reverse indispo → repli */ }
|
||||
try {
|
||||
const r = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${lon},${lat}.json?access_token=${MAPBOX_TOKEN}&language=fr&country=ca&types=address,poi`)
|
||||
const j = await r.json(); const f = (j.features || [])[0]
|
||||
if (f && f.place_name) locPicker.address = f.place_name.replace(/, Canada$/, '')
|
||||
} catch (e) { /* non bloquant */ }
|
||||
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&accept-language=fr&zoom=18`)
|
||||
const j = await r.json(); const a = j.address || {}
|
||||
const line1 = [a.house_number, a.road].filter(Boolean).join(' ')
|
||||
const city = a.city || a.town || a.village || a.municipality || a.hamlet || ''
|
||||
const parts = [line1, city, a.postcode].filter(Boolean)
|
||||
return parts.length ? parts.join(', ') : (j.display_name ? String(j.display_name).replace(/, Canada$/, '') : '')
|
||||
} catch (e) { return '' }
|
||||
}
|
||||
async function reverseGeocode (lon, lat) { const a = await reverseGeocodeOSM(lon, lat); if (a) locPicker.address = a }
|
||||
async function locSearch () {
|
||||
const q = (locPicker.search || '').trim(); if (q.length < 3) return
|
||||
locPicker.searching = true; locPicker.autoMatched = false
|
||||
|
|
@ -3301,12 +3403,9 @@ function focusAssignJobInList (name) {
|
|||
nextTick(() => { const el = document.querySelector('.assign-body [data-jobname="' + name + '"]'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center', behavior: 'smooth' }) })
|
||||
}
|
||||
async function initAssignMap () {
|
||||
if (!MAPBOX_TOKEN || !assignMapEl.value || _assignMap) return
|
||||
if (!assignMapEl.value || _assignMap) return
|
||||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !assignMapEl.value) return
|
||||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||||
_assignMap = new mapboxgl.Map({ container: assignMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 })
|
||||
_assignMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
|
||||
_assignMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
|
||||
_assignMap = createPlanMap(assignMapEl.value, { center: [-73.55, 45.2], zoom: 8, streetView: openStreetView })
|
||||
_assignMapRO = new ResizeObserver(() => { if (_assignMap) _assignMap.resize() }); _assignMapRO.observe(assignMapEl.value)
|
||||
_assignMap.on('load', () => {
|
||||
_assignMap.resize()
|
||||
|
|
@ -3589,16 +3688,7 @@ function setDayOrigin (kind) { dayOriginChoice.value = kind; if (_dayMap) refres
|
|||
// numérotés dans l'ordre de tournée. Navigable : zoom molette + boutons (NavigationControl), déplacement. ──
|
||||
const dayMapEl = ref(null)
|
||||
let _dayMap = null; let _dayMapRO = null; let _dirTimer = null; let _dayStopMarkers = []
|
||||
function ensureMapbox () {
|
||||
return new Promise((resolve) => {
|
||||
if (!document.getElementById('mapbox-css')) { const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'; l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l) }
|
||||
if (window.mapboxgl) return resolve(window.mapboxgl)
|
||||
let s = document.getElementById('mapbox-js')
|
||||
if (!s) { s = document.createElement('script'); s.id = 'mapbox-js'; s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'; document.head.appendChild(s) }
|
||||
s.addEventListener('load', () => resolve(window.mapboxgl))
|
||||
const iv = setInterval(() => { if (window.mapboxgl) { clearInterval(iv); resolve(window.mapboxgl) } }, 150)
|
||||
})
|
||||
}
|
||||
function ensureMapbox () { return Promise.resolve(installMaplibre()) } // MapLibre bundlé + protocole pmtiles:// (alias window.mapboxgl pour compat)
|
||||
const _esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
||||
function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + infos pour le popup
|
||||
return packedDay.value.filter(hasLL).map((j, i) => ({
|
||||
|
|
@ -3648,12 +3738,9 @@ function bulkCloseSelected () {
|
|||
}
|
||||
function fmtDT (iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' } }
|
||||
async function initDayMap () {
|
||||
if (!MAPBOX_TOKEN || !dayMapEl.value || _dayMap) return
|
||||
if (!dayMapEl.value || _dayMap) return
|
||||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !dayMapEl.value) return
|
||||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||||
_dayMap = new mapboxgl.Map({ container: dayMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.6756, 45.1599], zoom: 9 })
|
||||
_dayMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right') // boutons zoom +/-
|
||||
_dayMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
|
||||
_dayMap = createPlanMap(dayMapEl.value, { center: [-73.6756, 45.1599], zoom: 9, streetView: openStreetView })
|
||||
_dayMapRO = new ResizeObserver(() => { if (_dayMap) _dayMap.resize() }); _dayMapRO.observe(dayMapEl.value)
|
||||
_dayMap.on('load', () => {
|
||||
_dayMap.resize()
|
||||
|
|
@ -4310,11 +4397,18 @@ function selectAllSector (city) {
|
|||
// ── D : SUGGESTION de répartition (glouton proximité + charge + compétence), REVUE en surcouche avant d'appliquer ──
|
||||
const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: false, applying: false, fromSel: false, techSel: {}, makeShifts: true, strategy: 'optimize', view: 'list', jobTime: {}, includeAssigned: false }) // défaut = solveur VRP · strategy: optimize|smart|best|balance|enough · includeAssigned = simulation re-optimisant AUSSI les jobs déjà assignés
|
||||
// Noms des jobs DÉJÀ assignés sur la fenêtre (pour la simulation « ré-optimiser l'existant ») — depuis l'occupation.
|
||||
function assignedNamesForWindow () {
|
||||
const win = new Set(suggestWindow.value); const out = new Set()
|
||||
for (const key in occByTechDay.value) { if (!win.has(key.split('|')[1])) continue; for (const n of (occByTechDay.value[key].jobs || [])) if (n) out.add(n) }
|
||||
return [...out]
|
||||
// Jobs DÉJÀ assignés dans la fenêtre (objets complets : subject/detail → détection AM/PM). occ.jobs = objets, pas des noms.
|
||||
function assignedJobsForWindow () {
|
||||
const win = new Set(suggestWindow.value); const out = []; const seen = new Set()
|
||||
for (const key in occByTechDay.value) {
|
||||
if (!win.has(key.split('|')[1])) continue
|
||||
for (const j of (occByTechDay.value[key].jobs || [])) { const n = j && j.name; if (n && !seen.has(n)) { seen.add(n); out.push(j) } }
|
||||
}
|
||||
return out
|
||||
}
|
||||
function assignedNamesForWindow () { return assignedJobsForWindow().map(j => j.name).filter(Boolean) }
|
||||
// AUTO AM/PM sur les jobs déjà assignés (ré-optimiser l'existant) : sinon la « Note AM » d'un job dispatché serait ignorée → placé en fin de tournée.
|
||||
function seedAssignedAmpm () { for (const j of assignedJobsForWindow()) { if (!suggestDlg.jobTime[j.name]) { const ap = detectAmpm(j); if (ap) suggestDlg.jobTime[j.name] = { ampm: ap, auto: true } } } }
|
||||
const prevOpen = reactive({}) // revue : sections « déjà assigné(s) » dépliées (clé techId|iso)
|
||||
// ── Revue : détail du ticket au clic + temps révisable + temps de route ENTRE arrêts (OSRM) ──
|
||||
const durOverride = reactive({}) // jobName → heures révisées par le dispatcher (survit aux Ré-optimiser de la session)
|
||||
|
|
@ -4748,6 +4842,7 @@ function shiftWindowMin (techId, iso) {
|
|||
async function optimizeSuggestion () {
|
||||
suggestDlg.makeShifts = true // quarts 8-16 assumés (hub assumed_shift) → créés au Publier
|
||||
const selTechs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id])
|
||||
if (suggestDlg.includeAssigned) seedAssignedAmpm() // ré-optimiser l'existant : détecte AM/PM des jobs déjà dispatchés (fenêtre dure 8-12 / 12-16)
|
||||
const job_windows = {}
|
||||
for (const n in suggestDlg.jobTime) { const w = AMPM_WIN[(suggestDlg.jobTime[n] || {}).ampm]; if (w) job_windows[n] = { tw_start_min: w[0], tw_end_min: w[1] } }
|
||||
try {
|
||||
|
|
@ -4990,6 +5085,10 @@ const routeUnassignedPins = computed(() => {
|
|||
|
||||
// ── Deep-link (?day=YYYY-MM-DD & ?skill=…) + action « planifier des quarts » du slot-finder ──
|
||||
const route = useRoute()
|
||||
const createSignal = useCreateSignal()
|
||||
// FAB global « Créer » → signal partagé (useCreateSignal) → ouvre le chooser unifié. Ref module-level = survit à la nav
|
||||
// (fresh mount ET même page). nextTick → posé après le rendu (fiable, contrairement au router.replace-au-setup).
|
||||
watch(() => createSignal.pending.value, (v) => { if (v) { createSignal.pending.value = false; nextTick(() => { createChooser.value = true }) } }, { immediate: true })
|
||||
function matchSkill (skill) { return skill ? (allSkills.value || []).find(s => String(s).toLowerCase() === String(skill).toLowerCase()) : null }
|
||||
function focusDay (iso, skill) { // cadre la semaine sur le jour, le sélectionne (grille/mobile/tournées), filtre compétence si connue
|
||||
if (iso && /^\d{4}-\d{2}-\d{2}$/.test(iso)) { start.value = mondayISO(iso); mobileSelIso.value = iso; routesDay.value = iso }
|
||||
|
|
@ -5419,7 +5518,7 @@ async function refreshTemplates () { const tp = await roster.listTemplates(); te
|
|||
|
||||
// cadence / efficacité par tech
|
||||
// Libellé orienté PERFORMANCE/vitesse (≠ libellé "facteur temps") : facteur 0,9 = plus rapide, 1,2 = plus lent.
|
||||
function effSuffix (e) { const d = Math.round((1 - Number(e)) * 100); if (!d) return 'normal'; return d > 0 ? ('+' + d + '% vite') : ('−' + Math.abs(d) + '% lent') }
|
||||
function effSuffix (e) { const p = Math.round(100 / (Number(e) || 1)); if (p === 100) return 'cadence normale'; return 'cadence ' + p + '% (' + (p > 100 ? 'plus de jobs' : 'moins de jobs') + ')' }
|
||||
// RÔLE dérivé des compétences (tags) : support→casque · installation→échelle · réparation/terrain→handyman.
|
||||
const ROLE_ICON = { support: symOutlinedHeadsetMic, install: symOutlinedToolsLadder, repair: symOutlinedHandyman }
|
||||
const ROLE_LABEL = { support: 'Support / service client', install: 'Installation', repair: 'Réparation / terrain' }
|
||||
|
|
@ -6176,6 +6275,13 @@ tr.res-hidden .hide-eye { opacity: 1; }
|
|||
.de-msg:first-of-type { border-top: none; }
|
||||
.de-msg-hdr { font-size: 11px; font-weight: 700; color: #5e35b1; }
|
||||
.de-msg-txt { font-size: 11px; white-space: pre-wrap; color: #37474f; }
|
||||
/* Dernier commentaire (le plus récent) mis en avant — info potentiellement la plus importante. */
|
||||
.de-msg-latest { background: #f3f0fb; border-radius: 6px; padding: 6px 8px; margin-bottom: 4px; }
|
||||
.de-msg-badge { display: inline-block; margin-left: 6px; font-size: 9px; font-weight: 700; color: #fff; background: #7e57c2; border-radius: 4px; padding: 0 5px; vertical-align: middle; }
|
||||
/* Chooser « Créer » : options pleine largeur, contenu aligné à gauche (icône + titre + sous-titre). */
|
||||
.cc-opt { width: 100%; border-radius: 10px; padding: 12px 14px; }
|
||||
.cc-opt :deep(.q-btn__content) { flex-wrap: nowrap; justify-content: flex-start; }
|
||||
.cc-sub { align-self: flex-start; margin: -4px 0 2px 8px; }
|
||||
.assign-thread { background: #f5f5f5; border-radius: 5px; padding: 4px 7px; margin-top: 4px; max-height: 220px; overflow: auto; }
|
||||
.assign-msg { font-size: 11px; border-top: 1px solid #e0e0e0; padding: 3px 0; }
|
||||
.assign-msg:first-child { border-top: none; }
|
||||
|
|
|
|||
7
apps/website-vue/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
*.local
|
||||
.DS_Store
|
||||
.vite/
|
||||
*.log
|
||||
79
apps/website-vue/PORTING_GUIDE.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# React → Vue port conventions (gigafibre website)
|
||||
|
||||
We are porting the React site at `../website` (React 18 + Vite + Tailwind 3 + shadcn/ui + Radix)
|
||||
to this Vue 3 app (`../website-vue`, Vue 3 `<script setup>` + Vite + Tailwind 3 + shadcn-vue + reka-ui).
|
||||
**Goal: pixel-identical output.** The Tailwind config, CSS tokens, fonts and custom utilities are already
|
||||
copied verbatim, so **keep the exact same Tailwind class strings** on every element.
|
||||
|
||||
## The golden rule
|
||||
Copy the JSX markup and its `className` strings **unchanged** into the Vue `<template>` (rename `className`→`class`).
|
||||
The visual result comes from those classes; do not "improve" or restructure them.
|
||||
|
||||
## Mechanical mappings
|
||||
| React | Vue |
|
||||
|---|---|
|
||||
| `className="..."` | `class="..."` |
|
||||
| `className={cn(a, b)}` | `:class="cn(a, b)"` (import `cn` from `@/lib/utils`) |
|
||||
| `{expr}` in JSX text | `{{ expr }}` |
|
||||
| `{items.map(x => <Card .../>)}` | `<Card v-for="x in items" :key="x.id" ... />` |
|
||||
| `{cond && <X/>}` | `<X v-if="cond" />` |
|
||||
| `{cond ? <A/> : <B/>}` | `<A v-if="cond" /> <B v-else />` |
|
||||
| `onClick={fn}` | `@click="fn"` |
|
||||
| `onChange={e => setX(e.target.value)}` | `v-model="x"` on inputs |
|
||||
| `useState(v)` | `const x = ref(v)` (`.value` in script, bare in template) |
|
||||
| `useEffect(fn, [])` | `onMounted(fn)` |
|
||||
| `useEffect(fn, [dep])` | `watch(() => dep, fn)` |
|
||||
| `<Link to="/x">` (react-router) | `<RouterLink to="/x">` |
|
||||
| `useNavigate()` → `navigate("/x")` | `const router = useRouter(); router.push("/x")` |
|
||||
| `useLocation()` | `const route = useRoute()` |
|
||||
| lucide-react `import { Wifi } from "lucide-react"` | `import { Wifi } from "lucide-vue-next"` (same icon names) |
|
||||
| `<Icon className="h-5 w-5" />` | `<Icon class="h-5 w-5" />` |
|
||||
| framer-motion `<motion.div initial=... animate=... />` | `<div v-motion :initial="..." :enter="...">` (from `@vueuse/motion`); `whileInView` → `:visible-once="..."` |
|
||||
| `AnimatePresence` | usually a `<Transition>` or just `v-if` — keep it simple, match the visual |
|
||||
| Toasts: `useToast()` / `toast(...)` (shadcn) | `import { useToast } from "@/components/ui/toast/use-toast"` (same API) |
|
||||
| `sonner` `toast()` | `import { toast } from "vue-sonner"` |
|
||||
| react-hook-form + zod | `vee-validate` + `zod` via `@/components/ui/form`, OR for simple cases just `ref`s + manual zod `.safeParse` |
|
||||
|
||||
## shadcn-vue components
|
||||
Import from the folder index, e.g. `import { Button } from "@/components/ui/button"`,
|
||||
`import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"`.
|
||||
Component/prop names and the `variant`/`size` props match shadcn/ui. Slots replace React children.
|
||||
`asChild` exists in reka-ui too. Available under `src/components/ui/`: accordion, alert, alert-dialog,
|
||||
aspect-ratio, avatar, badge, breadcrumb, button, calendar, card, carousel, checkbox, collapsible, command,
|
||||
context-menu, dialog, drawer, dropdown-menu, form, hover-card, input, label, menubar, navigation-menu,
|
||||
pagination, popover, progress, radio-group, resizable, scroll-area, select, separator, sheet, skeleton,
|
||||
slider, sonner, switch, table, tabs, textarea, toast, toggle, toggle-group, tooltip.
|
||||
|
||||
## Structure of a ported page
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Wifi } from "lucide-vue-next";
|
||||
import Header from "@/components/layout/Header.vue";
|
||||
import Footer from "@/components/layout/Footer.vue";
|
||||
// ...page-local data/state
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background">
|
||||
<Header />
|
||||
<!-- exact JSX markup, className→class -->
|
||||
<Footer />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Do / Don't
|
||||
- DO keep every Tailwind class string identical to the React source.
|
||||
- DO keep the same DOM structure, headings, copy (French text) and image URLs.
|
||||
- DO reuse `Header`, `Footer`, `BusinessPageLayout`, `GlassCard`, `Logo` from `@/components/...`.
|
||||
- DON'T add new colors, spacing, or restyle. DON'T invent content.
|
||||
- DON'T pull in new deps; everything needed is installed.
|
||||
- If a React file uses `input-otp` (not in the shadcn-vue registry), use a plain grouped `Input` set or note it.
|
||||
- If something can't be perfectly matched, leave a `<!-- TODO(port): ... -->` and keep the closest visual.
|
||||
|
||||
## Assets
|
||||
Static files live in `public/` and resolve under `/next/...` (Vite `base: '/next/'`). Copy any images the
|
||||
React app references from `../website/public` into `./public` (same relative paths).
|
||||
20
apps/website-vue/components.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "default",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"composables": "@/composables",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
7
apps/website-vue/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
24
apps/website-vue/index.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- Aperçu /next : ne PAS indexer tant que la refonte n'a pas remplacé le site à la racine (évite le duplicate-content SEO). -->
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" href="/next/favicon.ico" type="image/x-icon" />
|
||||
<title>Targo</title>
|
||||
<meta name="description" content="TARGO – Internet fibre optique, télévision HD et téléphonie résidentielle. 100% fibre locale au Québec." />
|
||||
<meta name="author" content="TARGO Communications" />
|
||||
|
||||
<meta property="og:title" content="TARGO – Internet fibre optique locale" />
|
||||
<meta property="og:description" content="Internet fibre optique, télévision HD et téléphonie résidentielle. 100% fibre locale au Québec." />
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
4138
apps/website-vue/package-lock.json
generated
Normal file
44
apps/website-vue/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "gigafibre-website-vue",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.23.0",
|
||||
"@supabase/supabase-js": "^2.110.0",
|
||||
"@tanstack/vue-query": "^5.101.2",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/motion": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-vue": "^8.6.0",
|
||||
"fflate": "^0.8.3",
|
||||
"html2pdf.js": "^0.10.3",
|
||||
"lucide-vue-next": "^0.462.0",
|
||||
"reka-ui": "^2.10.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vaul-vue": "^0.4.1",
|
||||
"vee-validate": "^4.15.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-sonner": "^1.3.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
6
apps/website-vue/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
apps/website-vue/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 352 B |
1
apps/website-vue/public/placeholder.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
14
apps/website-vue/public/robots.txt
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
User-agent: Googlebot
|
||||
Allow: /
|
||||
|
||||
User-agent: Bingbot
|
||||
Allow: /
|
||||
|
||||
User-agent: Twitterbot
|
||||
Allow: /
|
||||
|
||||
User-agent: facebookexternalhit
|
||||
Allow: /
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
15
apps/website-vue/src/App.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
// Miroir de App.tsx : TooltipProvider global + toasts (shadcn + sonner) + RouterView.
|
||||
// ScrollToTop est géré par le scrollBehavior du router.
|
||||
import { TooltipProvider } from "reka-ui";
|
||||
import { Toaster } from "@/components/ui/toast";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<RouterView />
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
BIN
apps/website-vue/src/assets/hero-family-3d.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
apps/website-vue/src/assets/hero-family.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
apps/website-vue/src/assets/hero-family.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
114
apps/website-vue/src/assets/hero-family.svg
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 280" fill="none">
|
||||
<!-- Couch -->
|
||||
<rect x="30" y="175" width="300" height="55" rx="10" fill="#d4c4a8"/>
|
||||
<rect x="40" y="158" width="280" height="25" rx="6" fill="#e8dcc8"/>
|
||||
<ellipse cx="40" cy="190" rx="15" ry="28" fill="#d4c4a8"/>
|
||||
<ellipse cx="320" cy="190" rx="15" ry="28" fill="#d4c4a8"/>
|
||||
<rect x="55" y="230" width="6" height="12" rx="2" fill="#242a28"/>
|
||||
<rect x="299" y="230" width="6" height="12" rx="2" fill="#242a28"/>
|
||||
|
||||
<!-- Dad (right side) -->
|
||||
<g transform="translate(230, 75)">
|
||||
<!-- Body -->
|
||||
<rect x="12" y="50" width="36" height="48" rx="6" fill="#242a28"/>
|
||||
<!-- Head -->
|
||||
<circle cx="30" cy="28" r="22" fill="#f5d0c5"/>
|
||||
<!-- Hair -->
|
||||
<path d="M10 22 Q10 6 30 6 Q50 6 50 22" fill="#242a28"/>
|
||||
<!-- Eyes -->
|
||||
<circle cx="22" cy="26" r="2.5" fill="#242a28"/>
|
||||
<circle cx="38" cy="26" r="2.5" fill="#242a28"/>
|
||||
<!-- Smile -->
|
||||
<path d="M23 38 Q30 44 37 38" stroke="#242a28" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<!-- Arms -->
|
||||
<rect x="2" y="55" width="12" height="6" rx="3" fill="#242a28"/>
|
||||
<rect x="46" y="55" width="12" height="6" rx="3" fill="#242a28"/>
|
||||
<!-- Hands -->
|
||||
<circle cx="0" cy="58" r="6" fill="#f5d0c5"/>
|
||||
<circle cx="60" cy="58" r="6" fill="#f5d0c5"/>
|
||||
<!-- Legs -->
|
||||
<rect x="16" y="98" width="10" height="32" rx="4" fill="#242a28"/>
|
||||
<rect x="34" y="98" width="10" height="32" rx="4" fill="#242a28"/>
|
||||
</g>
|
||||
|
||||
<!-- Mom (left side) -->
|
||||
<g transform="translate(70, 80)">
|
||||
<!-- Body -->
|
||||
<rect x="12" y="48" width="36" height="45" rx="6" fill="#ffffff" stroke="#e0e0e0"/>
|
||||
<!-- Head -->
|
||||
<circle cx="30" cy="26" r="21" fill="#f5d0c5"/>
|
||||
<!-- Hair -->
|
||||
<path d="M10 30 Q6 12 30 6 Q54 12 50 30 Q48 18 30 16 Q12 18 10 30" fill="#3d2314"/>
|
||||
<path d="M10 30 Q8 48 15 58" stroke="#3d2314" stroke-width="5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M50 30 Q52 48 45 58" stroke="#3d2314" stroke-width="5" fill="none" stroke-linecap="round"/>
|
||||
<!-- Eyes -->
|
||||
<circle cx="22" cy="24" r="2" fill="#242a28"/>
|
||||
<circle cx="38" cy="24" r="2" fill="#242a28"/>
|
||||
<!-- Smile -->
|
||||
<path d="M23 35 Q30 40 37 35" stroke="#242a28" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<!-- Arms -->
|
||||
<rect x="46" y="52" width="12" height="5" rx="2.5" fill="#f5d0c5"/>
|
||||
<rect x="2" y="52" width="12" height="5" rx="2.5" fill="#f5d0c5"/>
|
||||
<!-- Hands -->
|
||||
<circle cx="60" cy="54" r="5" fill="#f5d0c5"/>
|
||||
<circle cx="0" cy="54" r="5" fill="#f5d0c5"/>
|
||||
<!-- Legs -->
|
||||
<rect x="16" y="93" width="10" height="28" rx="4" fill="#242a28"/>
|
||||
<rect x="34" y="93" width="10" height="28" rx="4" fill="#242a28"/>
|
||||
</g>
|
||||
|
||||
<!-- Child 1 (boy, center-left) -->
|
||||
<g transform="translate(135, 105)">
|
||||
<!-- Body -->
|
||||
<rect x="8" y="36" width="28" height="32" rx="5" fill="#22c55e"/>
|
||||
<!-- Head -->
|
||||
<circle cx="22" cy="18" r="16" fill="#f5d0c5"/>
|
||||
<!-- Hair -->
|
||||
<path d="M8 14 Q8 4 22 4 Q36 4 36 14" fill="#242a28"/>
|
||||
<!-- Eyes -->
|
||||
<circle cx="16" cy="16" r="2" fill="#242a28"/>
|
||||
<circle cx="28" cy="16" r="2" fill="#242a28"/>
|
||||
<!-- Smile -->
|
||||
<path d="M17 26 Q22 30 27 26" stroke="#242a28" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
<!-- Arms -->
|
||||
<rect x="34" y="40" width="10" height="4" rx="2" fill="#22c55e"/>
|
||||
<rect x="0" y="40" width="10" height="4" rx="2" fill="#22c55e"/>
|
||||
<!-- Hands -->
|
||||
<circle cx="46" cy="42" r="4" fill="#f5d0c5"/>
|
||||
<circle cx="-2" cy="42" r="4" fill="#f5d0c5"/>
|
||||
<!-- Legs -->
|
||||
<rect x="11" y="68" width="8" height="22" rx="3" fill="#242a28"/>
|
||||
<rect x="25" y="68" width="8" height="22" rx="3" fill="#242a28"/>
|
||||
</g>
|
||||
|
||||
<!-- Child 2 (girl, center-right) -->
|
||||
<g transform="translate(180, 108)">
|
||||
<!-- Body/Dress -->
|
||||
<rect x="8" y="34" width="28" height="30" rx="5" fill="#ffffff" stroke="#e0e0e0"/>
|
||||
<!-- Head -->
|
||||
<circle cx="22" cy="16" r="15" fill="#f5d0c5"/>
|
||||
<!-- Hair -->
|
||||
<path d="M8 14 Q8 2 22 2 Q36 2 36 14" fill="#3d2314"/>
|
||||
<path d="M8 14 Q6 24 10 30" stroke="#3d2314" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<path d="M36 14 Q38 24 34 30" stroke="#3d2314" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<!-- Eyes -->
|
||||
<circle cx="16" cy="14" r="2" fill="#242a28"/>
|
||||
<circle cx="28" cy="14" r="2" fill="#242a28"/>
|
||||
<!-- Smile -->
|
||||
<path d="M17 23 Q22 27 27 23" stroke="#242a28" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
<!-- Arms -->
|
||||
<rect x="34" y="38" width="10" height="4" rx="2" fill="#f5d0c5"/>
|
||||
<rect x="0" y="38" width="10" height="4" rx="2" fill="#f5d0c5"/>
|
||||
<!-- Hands -->
|
||||
<circle cx="46" cy="40" r="4" fill="#f5d0c5"/>
|
||||
<circle cx="-2" cy="40" r="4" fill="#f5d0c5"/>
|
||||
<!-- Legs -->
|
||||
<rect x="11" y="64" width="8" height="20" rx="3" fill="#242a28"/>
|
||||
<rect x="25" y="64" width="8" height="20" rx="3" fill="#242a28"/>
|
||||
</g>
|
||||
|
||||
<!-- Decorative dots -->
|
||||
<circle cx="50" cy="55" r="4" fill="#22c55e" opacity="0.5"/>
|
||||
<circle cx="310" cy="45" r="5" fill="#22c55e" opacity="0.4"/>
|
||||
<circle cx="180" cy="35" r="3" fill="#22c55e" opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
BIN
apps/website-vue/src/assets/hero-living-room-3d.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
apps/website-vue/src/assets/pain-buffering.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/website-vue/src/assets/pain-business.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/website-vue/src/assets/solution-business.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/website-vue/src/assets/solution-connected.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/website-vue/src/assets/targo-bureaux.jpg
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
1
apps/website-vue/src/assets/targo-logo-green.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35"><defs><style>.cls-1{fill:#019547;}</style></defs><path class="cls-1" d="M25.83,15H8.41a4.14,4.14,0,0,1-3.89-2.71L0,1.18H66.59L62.07,12.27A4.14,4.14,0,0,1,58.18,15H40.76V69.19H25.81Z"/><path class="cls-1" d="M90.74.68h18.55a5.63,5.63,0,0,1,5.63,5.6l.26,62.91H99.55L99.76,54H71.87L66.41,65.51a6.45,6.45,0,0,1-5.84,3.68H49.35L73.53,12.11A18.7,18.7,0,0,1,90.74.68M100,40.73l.07-26h-8a6.75,6.75,0,0,0-6.26,4.18L76.73,40.73Z"/><path class="cls-1" d="M124.51,6.81a5.64,5.64,0,0,1,5.65-5.65H155.6c8.64,0,15.37,2.44,19.81,6.91,3.81,3.78,5.84,9.14,5.84,15.53v.18c0,11-5.92,17.87-14.59,21.1l16.61,24.29h-14a6.51,6.51,0,0,1-5.39-2.87L151.21,47.41H139.44V69.17h-15Zm30.12,27.41c7.28,0,11.45-3.89,11.45-9.62v-.21c0-6.42-4.46-9.7-11.74-9.7H139.46V34.22Z"/><path class="cls-1" d="M184.74,35.37v-.18C184.74,15.85,199.8,0,220.4,0,232.65,0,240,3.31,247.13,9.33l-6.28,7.57a5.18,5.18,0,0,1-6.84,1,23.71,23.71,0,0,0-14.11-4.13c-10.88,0-19.52,9.62-19.52,21.18v.19c0,12.43,8.54,21.57,20.6,21.57a24,24,0,0,0,14.09-4.07V42.91h-8.68A6.41,6.41,0,0,1,220,36.53V30h29.54V59.52a44,44,0,0,1-29,10.78c-21.18,0-35.74-14.8-35.74-34.93"/><path class="cls-1" d="M254.09,35.37v-.18C254.09,15.85,269.33,0,290.33,0s36.06,15.66,36.06,35v.18c0,19.34-15.27,35.19-36.24,35.19s-36.06-15.64-36.06-35m56.63,0v-.18c0-11.67-8.54-21.39-20.6-21.39S269.73,23.34,269.73,35v.18c0,11.67,8.54,21.37,20.6,21.37S310.72,47,310.72,35.37"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
apps/website-vue/src/assets/targo-logo-white.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35"><defs><style>.cls-1{fill:#ffffff;}</style></defs><path class="cls-1" d="M25.83,15H8.41a4.14,4.14,0,0,1-3.89-2.71L0,1.18H66.59L62.07,12.27A4.14,4.14,0,0,1,58.18,15H40.76V69.19H25.81Z"/><path class="cls-1" d="M90.74.68h18.55a5.63,5.63,0,0,1,5.63,5.6l.26,62.91H99.55L99.76,54H71.87L66.41,65.51a6.45,6.45,0,0,1-5.84,3.68H49.35L73.53,12.11A18.7,18.7,0,0,1,90.74.68M100,40.73l.07-26h-8a6.75,6.75,0,0,0-6.26,4.18L76.73,40.73Z"/><path class="cls-1" d="M124.51,6.81a5.64,5.64,0,0,1,5.65-5.65H155.6c8.64,0,15.37,2.44,19.81,6.91,3.81,3.78,5.84,9.14,5.84,15.53v.18c0,11-5.92,17.87-14.59,21.1l16.61,24.29h-14a6.51,6.51,0,0,1-5.39-2.87L151.21,47.41H139.44V69.17h-15Zm30.12,27.41c7.28,0,11.45-3.89,11.45-9.62v-.21c0-6.42-4.46-9.7-11.74-9.7H139.46V34.22Z"/><path class="cls-1" d="M184.74,35.37v-.18C184.74,15.85,199.8,0,220.4,0,232.65,0,240,3.31,247.13,9.33l-6.28,7.57a5.18,5.18,0,0,1-6.84,1,23.71,23.71,0,0,0-14.11-4.13c-10.88,0-19.52,9.62-19.52,21.18v.19c0,12.43,8.54,21.57,20.6,21.57a24,24,0,0,0,14.09-4.07V42.91h-8.68A6.41,6.41,0,0,1,220,36.53V30h29.54V59.52a44,44,0,0,1-29,10.78c-21.18,0-35.74-14.8-35.74-34.93"/><path class="cls-1" d="M254.09,35.37v-.18C254.09,15.85,269.33,0,290.33,0s36.06,15.66,36.06,35v.18c0,19.34-15.27,35.19-36.24,35.19s-36.06-15.64-36.06-35m56.63,0v-.18c0-11.67-8.54-21.39-20.6-21.39S269.73,23.34,269.73,35v.18c0,11.67,8.54,21.37,20.6,21.37S310.72,47,310.72,35.37"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
apps/website-vue/src/assets/targo-logo.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
apps/website-vue/src/assets/targo-technician-avatar.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
413
apps/website-vue/src/components/AvailabilityDialog.vue
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted } from "vue";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Search,
|
||||
MapPin,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
Zap,
|
||||
Mail,
|
||||
Phone,
|
||||
Send,
|
||||
} from "lucide-vue-next";
|
||||
import { z } from "zod";
|
||||
|
||||
// Address search via our own API (no Supabase dependency)
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
||||
|
||||
const phoneClean = (v: string) => v.replace(/[\s\-().]/g, "");
|
||||
|
||||
const contactSchema = z.union([
|
||||
z.string().trim().email().max(254),
|
||||
z.string().trim().transform(phoneClean).pipe(
|
||||
z.string().regex(/^\+?1?\d{10,11}$/, "Numéro invalide"),
|
||||
),
|
||||
]);
|
||||
|
||||
const CONTACT_COOLDOWN_MS = 5000;
|
||||
|
||||
interface AddressResult {
|
||||
id: number;
|
||||
address_full: string;
|
||||
numero: string;
|
||||
rue: string;
|
||||
ville: string;
|
||||
code_postal: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
fiber_available: boolean;
|
||||
zone_tarifaire: number;
|
||||
max_speed: number;
|
||||
similarity_score: number;
|
||||
}
|
||||
|
||||
type Step = "search" | "result";
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ "update:open": [value: boolean] }>();
|
||||
|
||||
const openModel = computed({
|
||||
get: () => props.open,
|
||||
set: (v: boolean) => emit("update:open", v),
|
||||
});
|
||||
|
||||
const query = ref("");
|
||||
const results = ref<AddressResult[]>([]);
|
||||
const searching = ref(false);
|
||||
const selected = ref<AddressResult | null>(null);
|
||||
const step = ref<Step>("search");
|
||||
const checking = ref(false);
|
||||
const contactValue = ref("");
|
||||
const contactSent = ref(false);
|
||||
const sendingContact = ref(false);
|
||||
const contactError = ref("");
|
||||
let lastSubmit = 0;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// Reset on close
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
query.value = "";
|
||||
results.value = [];
|
||||
selected.value = null;
|
||||
step.value = "search";
|
||||
contactValue.value = "";
|
||||
contactSent.value = false;
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Cleanup debounce on unmount
|
||||
onUnmounted(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
});
|
||||
|
||||
const searchAddresses = async (term: string) => {
|
||||
if (term.length < 3) {
|
||||
results.value = [];
|
||||
return;
|
||||
}
|
||||
searching.value = true;
|
||||
try {
|
||||
const res = await fetch(API_BASE + "/rpc/search_addresses", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ search_term: term, result_limit: 8 }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
results.value = data as AddressResult[];
|
||||
}
|
||||
} finally {
|
||||
searching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleQueryChange = (value: string) => {
|
||||
query.value = value;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => searchAddresses(value), 350);
|
||||
};
|
||||
|
||||
const handleValidate = async (address: AddressResult) => {
|
||||
selected.value = address;
|
||||
checking.value = true;
|
||||
step.value = "result";
|
||||
|
||||
// Log the search (fire and forget)
|
||||
try {
|
||||
console.log("[Search]", query.value, "→", address.address_full, address.fiber_available);
|
||||
} catch (e) {
|
||||
console.error("Log search error:", e);
|
||||
}
|
||||
|
||||
// Small delay for UX
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
checking.value = false;
|
||||
};
|
||||
|
||||
const handleContactSubmit = async () => {
|
||||
if (!contactValue.value.trim() || !selected.value) return;
|
||||
|
||||
// Rate limit
|
||||
const now = Date.now();
|
||||
if (now - lastSubmit < CONTACT_COOLDOWN_MS) {
|
||||
contactError.value = "Veuillez patienter quelques secondes.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate contact
|
||||
const result = contactSchema.safeParse(contactValue.value.trim());
|
||||
if (!result.success) {
|
||||
contactError.value = "Veuillez entrer un courriel ou numéro de mobile valide.";
|
||||
return;
|
||||
}
|
||||
contactError.value = "";
|
||||
sendingContact.value = true;
|
||||
lastSubmit = now;
|
||||
|
||||
const validContact = result.data;
|
||||
|
||||
// Send lead notification via Mailjet
|
||||
try {
|
||||
await fetch(API_BASE + "/rpc/lead", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contact: validContact,
|
||||
address: selected.value.address_full,
|
||||
fiber_available: selected.value.fiber_available,
|
||||
max_speed: selected.value.max_speed || 0,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Lead send error:", e);
|
||||
}
|
||||
|
||||
sendingContact.value = false;
|
||||
contactSent.value = true;
|
||||
};
|
||||
|
||||
const speedLabel = (speed: number) => {
|
||||
if (!speed || speed === 0) return "1.5 Gbit/s";
|
||||
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbit/s`;
|
||||
return `${speed} Mbit/s`;
|
||||
};
|
||||
|
||||
const backToSearch = () => {
|
||||
step.value = "search";
|
||||
selected.value = null;
|
||||
contactSent.value = false;
|
||||
contactValue.value = "";
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="openModel">
|
||||
<DialogContent class="sm:max-w-lg max-h-[85vh] overflow-hidden flex flex-col top-[8%] translate-y-0 sm:top-[50%] sm:translate-y-[-50%]">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2 text-xl">
|
||||
<Zap class="w-5 h-5 text-targo-green" />
|
||||
Vérifier la disponibilité
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Entrez votre adresse pour vérifier si la fibre optique est disponible
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Transition mode="out-in">
|
||||
<div
|
||||
v-if="step === 'search'"
|
||||
key="search"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, x: -20 }"
|
||||
:enter="{ opacity: 1, x: 0 }"
|
||||
:leave="{ opacity: 0, x: -20 }"
|
||||
class="space-y-4 flex-1 overflow-hidden flex flex-col"
|
||||
>
|
||||
<!-- Search input -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Ex: 1933 Avenue Goulet, Montréal"
|
||||
:model-value="query"
|
||||
class="pl-10"
|
||||
autofocus
|
||||
@update:model-value="(v) => handleQueryChange(String(v))"
|
||||
/>
|
||||
<Loader2 v-if="searching" class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<!-- Results list -->
|
||||
<div class="flex-1 overflow-y-auto space-y-1 max-h-[50vh]">
|
||||
<p v-if="results.length === 0 && query.length >= 3 && !searching" class="text-sm text-muted-foreground text-center py-8">
|
||||
Aucune adresse trouvée
|
||||
</p>
|
||||
<button
|
||||
v-for="addr in results"
|
||||
:key="addr.id"
|
||||
class="w-full text-left p-3 rounded-lg border border-border hover:border-targo-green hover:bg-targo-green/5 transition-all duration-200 group"
|
||||
@click="handleValidate(addr)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<MapPin class="w-4 h-4 mt-0.5 text-muted-foreground group-hover:text-targo-green shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium truncate">
|
||||
{{ addr.address_full }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ addr.ville }} {{ addr.code_postal }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="addr.fiber_available" class="ml-auto shrink-0 text-[10px] font-bold uppercase tracking-wider text-targo-green bg-targo-green/10 px-2 py-0.5 rounded-full">
|
||||
Fibre
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="step === 'result' && selected"
|
||||
key="result"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, x: 20 }"
|
||||
:enter="{ opacity: 1, x: 0 }"
|
||||
:leave="{ opacity: 0, x: 20 }"
|
||||
class="space-y-6"
|
||||
>
|
||||
<div v-if="checking" class="flex flex-col items-center py-12 gap-4">
|
||||
<Loader2 class="w-10 h-10 animate-spin text-targo-green" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Vérification en cours…
|
||||
</p>
|
||||
</div>
|
||||
<!-- ✅ AVAILABLE -->
|
||||
<div v-else-if="selected.fiber_available" class="space-y-6">
|
||||
<div class="text-center space-y-3">
|
||||
<div class="w-16 h-16 rounded-full bg-targo-green/10 flex items-center justify-center mx-auto">
|
||||
<CheckCircle2 class="w-8 h-8 text-targo-green" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-foreground">
|
||||
Félicitations !
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Le service est disponible à votre adresse jusqu'à{{ " " }}
|
||||
<span class="font-bold text-targo-green">
|
||||
{{ speedLabel(selected.max_speed) }}
|
||||
</span>
|
||||
</p>
|
||||
<div class="bg-primary/5 border border-primary/20 rounded-xl p-4 mt-2">
|
||||
<p class="text-xs text-muted-foreground mb-1">Votre forfait</p>
|
||||
<p class="text-2xl font-bold text-primary font-display">
|
||||
À partir de 39<sup class="text-sm">,95</sup>$/mois
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground bg-muted/50 rounded-lg p-3">
|
||||
<MapPin class="inline w-3 h-3 mr-1" />
|
||||
{{ selected.address_full }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!contactSent" class="space-y-3">
|
||||
<p class="text-sm font-medium text-center">
|
||||
Inscrivez votre courriel ou numéro de mobile pour
|
||||
démarrer votre abonnement
|
||||
</p>
|
||||
<div class="space-y-1">
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
placeholder="courriel ou mobile"
|
||||
:model-value="contactValue"
|
||||
class="flex-1"
|
||||
:maxlength="254"
|
||||
@update:model-value="(v) => { contactValue = String(v); contactError = ''; }"
|
||||
/>
|
||||
<Button
|
||||
:disabled="!contactValue.trim() || sendingContact"
|
||||
class="gradient-targo"
|
||||
@click="handleContactSubmit"
|
||||
>
|
||||
<Loader2 v-if="sendingContact" class="w-4 h-4 animate-spin" />
|
||||
<Send v-else class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="contactError" class="text-xs text-destructive">{{ contactError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center text-sm text-targo-green font-medium bg-targo-green/10 rounded-lg p-4">
|
||||
<CheckCircle2 class="w-5 h-5 mx-auto mb-2" />
|
||||
Merci ! Nous vous contacterons très bientôt.
|
||||
</div>
|
||||
</div>
|
||||
<!-- ❌ NOT AVAILABLE -->
|
||||
<div v-else class="space-y-6">
|
||||
<div class="text-center space-y-3">
|
||||
<div class="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mx-auto">
|
||||
<XCircle class="w-8 h-8 text-destructive" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-foreground">
|
||||
Adresse non confirmée
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Nous n'arrivons pas à confirmer la disponibilité pour
|
||||
votre adresse.
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground bg-muted/50 rounded-lg p-3">
|
||||
<MapPin class="inline w-3 h-3 mr-1" />
|
||||
{{ selected.address_full }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-center gap-4 text-sm">
|
||||
<a
|
||||
href="tel:18558882746"
|
||||
class="flex items-center gap-2 text-targo-green hover:underline font-medium"
|
||||
>
|
||||
<Phone class="w-4 h-4" />
|
||||
1-855-888-2746
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-xs text-muted-foreground">
|
||||
ou inscrivez votre courriel et nous vous contacterons
|
||||
</div>
|
||||
|
||||
<div v-if="!contactSent" class="space-y-1">
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
placeholder="votre courriel"
|
||||
:model-value="contactValue"
|
||||
class="flex-1"
|
||||
:maxlength="254"
|
||||
@update:model-value="(v) => { contactValue = String(v); contactError = ''; }"
|
||||
/>
|
||||
<Button
|
||||
:disabled="!contactValue.trim() || sendingContact"
|
||||
variant="outline"
|
||||
class="border-targo-green text-targo-green hover:bg-targo-green/5"
|
||||
@click="handleContactSubmit"
|
||||
>
|
||||
<Loader2 v-if="sendingContact" class="w-4 h-4 animate-spin" />
|
||||
<Mail v-else class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="contactError" class="text-xs text-destructive">{{ contactError }}</p>
|
||||
</div>
|
||||
<div v-else class="text-center text-sm text-targo-green font-medium bg-targo-green/10 rounded-lg p-4">
|
||||
<CheckCircle2 class="w-5 h-5 mx-auto mb-2" />
|
||||
Merci ! Nous vous contacterons très bientôt.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
@click="backToSearch"
|
||||
>
|
||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||
Rechercher une autre adresse
|
||||
</Button>
|
||||
</div>
|
||||
</Transition>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
73
apps/website-vue/src/components/ProtectedAdminRoute.vue
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<script setup lang="ts">
|
||||
// Vue port of React ProtectedAdminRoute.
|
||||
// Usage: wrap an admin page's content, e.g. in AdminImport.vue:
|
||||
// <ProtectedAdminRoute><AdminImportContent /></ProtectedAdminRoute>
|
||||
// It redirects to /admin/login (router.replace) when the user is not an admin.
|
||||
// (See report for the alternative router navigation-guard wiring.)
|
||||
import { ref, watch, onMounted, onUnmounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Loader2 } from "lucide-vue-next";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(true);
|
||||
const authorized = ref(false);
|
||||
let initialCheckDone = false;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const checkAdmin = async (userId: string) => {
|
||||
const { data: roleRow } = await supabase
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", userId)
|
||||
.eq("role", "admin")
|
||||
.maybeSingle();
|
||||
authorized.value = !!roleRow;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Listen to auth state changes — but ignore null session until initial check is done
|
||||
const { data } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (session?.user) {
|
||||
initialCheckDone = true;
|
||||
checkAdmin(session.user.id);
|
||||
} else if (initialCheckDone) {
|
||||
// Only redirect if we already confirmed the initial state
|
||||
authorized.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
unsubscribe = () => data.subscription.unsubscribe();
|
||||
|
||||
// Initial session check — this is the source of truth
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
initialCheckDone = true;
|
||||
if (session?.user) {
|
||||
checkAdmin(session.user.id);
|
||||
} else {
|
||||
authorized.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribe?.();
|
||||
});
|
||||
|
||||
// Redirect once we know the user is not authorized (equivalent of <Navigate to="/admin/login" replace />)
|
||||
watch([loading, authorized], ([l, a]) => {
|
||||
if (!l && !a) {
|
||||
router.replace("/admin/login");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="min-h-screen flex items-center justify-center">
|
||||
<Loader2 class="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<slot v-else-if="authorized" />
|
||||
</template>
|
||||
108
apps/website-vue/src/components/SearchDialog.vue
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { searchItems, type SearchItem } from "@/lib/searchData";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Search, Monitor, Briefcase, HelpCircle, FileText, Globe } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ "update:open": [value: boolean] }>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const runCommand = (command: () => unknown) => {
|
||||
emit("update:open", false);
|
||||
command();
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "Page":
|
||||
return Monitor;
|
||||
case "Business":
|
||||
return Briefcase;
|
||||
case "Support":
|
||||
return HelpCircle;
|
||||
case "Legal":
|
||||
return FileText;
|
||||
default:
|
||||
return Search;
|
||||
}
|
||||
};
|
||||
|
||||
const onSelect = (item: SearchItem) => {
|
||||
if (item.url.startsWith("http")) {
|
||||
runCommand(() => window.open(item.url, "_blank"));
|
||||
} else {
|
||||
runCommand(() => router.push(item.url));
|
||||
}
|
||||
};
|
||||
|
||||
const pages = searchItems.filter((item) => item.type === "Page");
|
||||
const business = searchItems.filter((item) => item.type === "Business");
|
||||
const support = searchItems.filter((item) => item.type === "Support");
|
||||
|
||||
const openModel = computed({
|
||||
get: () => props.open,
|
||||
set: (v: boolean) => emit("update:open", v),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommandDialog v-model:open="openModel">
|
||||
<CommandInput placeholder="Rechercher une page ou un service..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>Aucun résultat trouvé.</CommandEmpty>
|
||||
|
||||
<CommandGroup v-if="pages.length > 0" heading="Pages Principales">
|
||||
<CommandItem
|
||||
v-for="item in pages"
|
||||
:key="item.id"
|
||||
:value="`${item.title} ${item.keywords?.join(' ')}`"
|
||||
@select="onSelect(item)"
|
||||
>
|
||||
<component :is="getIcon(item.type)" class="mr-2 h-4 w-4" />
|
||||
<span>{{ item.title }}</span>
|
||||
<Globe v-if="item.id === 'client-portal'" class="ml-auto h-4 w-4 text-muted-foreground" />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup v-if="business.length > 0" heading="Solutions Affaires">
|
||||
<CommandItem
|
||||
v-for="item in business"
|
||||
:key="item.id"
|
||||
:value="`${item.title} ${item.keywords?.join(' ')}`"
|
||||
@select="onSelect(item)"
|
||||
>
|
||||
<component :is="getIcon(item.type)" class="mr-2 h-4 w-4" />
|
||||
<span>{{ item.title }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup v-if="support.length > 0" heading="Support & Aide">
|
||||
<CommandItem
|
||||
v-for="item in support"
|
||||
:key="item.id"
|
||||
:value="`${item.title} ${item.keywords?.join(' ')}`"
|
||||
@select="onSelect(item)"
|
||||
>
|
||||
<component :is="getIcon(item.type)" class="mr-2 h-4 w-4" />
|
||||
<span>{{ item.title }}</span>
|
||||
<Globe v-if="item.id === 'client-portal'" class="ml-auto h-4 w-4 text-muted-foreground" />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</template>
|
||||
178
apps/website-vue/src/components/Speedometer.vue
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ speed: string; size?: number }>(),
|
||||
{ size: 140 },
|
||||
);
|
||||
|
||||
const animatedValue = ref(0);
|
||||
|
||||
// Parse speed to get a normalized value (0-1) for indicator position
|
||||
// Uses logarithmic curve: fast climb for lower speeds, diminishing impact for higher speeds
|
||||
const getSpeedValue = (speedStr: string): number => {
|
||||
const lower = speedStr.toLowerCase();
|
||||
let mbps: number;
|
||||
|
||||
if (lower.includes("gbps")) {
|
||||
mbps = parseFloat(speedStr) * 1000; // Convert Gbps to Mbps
|
||||
} else {
|
||||
mbps = parseFloat(speedStr);
|
||||
}
|
||||
|
||||
// Logarithmic curve: log(1 + x * k) / log(1 + k)
|
||||
// Higher k = faster climb for lower speeds, compressed difference at higher speeds
|
||||
const maxSpeed = 3500;
|
||||
const k = 50; // Higher value for more aggressive lower speed progression
|
||||
const normalized = Math.min(mbps / maxSpeed, 1);
|
||||
|
||||
return Math.log(1 + normalized * k) / Math.log(1 + k);
|
||||
};
|
||||
|
||||
const targetValue = computed(() => getSpeedValue(props.speed));
|
||||
|
||||
// Animate on mount
|
||||
onMounted(() => {
|
||||
const duration = 1200;
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease-out cubic for smooth deceleration
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
animatedValue.value = eased * targetValue.value;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
});
|
||||
|
||||
// Parse speed into number and unit
|
||||
const speedNumber = computed(() => parseFloat(props.speed));
|
||||
const speedUnit = computed(() =>
|
||||
props.speed.toLowerCase().includes("gbps") ? "Gbps" : "Mbps",
|
||||
);
|
||||
|
||||
const strokeWidth = 14;
|
||||
const radius = computed(() => (props.size - strokeWidth) / 2);
|
||||
const center = computed(() => props.size / 2);
|
||||
|
||||
// Arc path for the gauge
|
||||
const polarToCartesian = (cx: number, cy: number, r: number, angleDeg: number) => {
|
||||
const rad = ((angleDeg - 90) * Math.PI) / 180;
|
||||
return {
|
||||
x: cx + r * Math.cos(rad),
|
||||
y: cy + r * Math.sin(rad),
|
||||
};
|
||||
};
|
||||
|
||||
const describeArc = (cx: number, cy: number, r: number, startAngle: number, endAngle: number) => {
|
||||
const start = polarToCartesian(cx, cy, r, endAngle);
|
||||
const end = polarToCartesian(cx, cy, r, startAngle);
|
||||
const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`;
|
||||
};
|
||||
|
||||
// Full arc from -135 to 135 (270 degrees)
|
||||
const arcPath = computed(() => describeArc(center.value, center.value, radius.value, -135, 135));
|
||||
|
||||
// Filled arc based on animated value
|
||||
const filledAngle = computed(() => -135 + animatedValue.value * 270);
|
||||
const filledArcPath = computed(() =>
|
||||
describeArc(center.value, center.value, radius.value, -135, filledAngle.value),
|
||||
);
|
||||
|
||||
// Position for the indicator circle
|
||||
const indicatorPos = computed(() =>
|
||||
polarToCartesian(center.value, center.value, radius.value, filledAngle.value),
|
||||
);
|
||||
|
||||
const uniqueId = computed(() => `speedGradient-${props.speed.replace(/\s/g, "-")}`);
|
||||
|
||||
// Gradient with lighter green tones
|
||||
const colors = { start: "#34d399", mid: "#4ade80", end: "#86efac" };
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-col items-center" :style="{ width: `${size}px`, height: `${size * 0.7}px` }">
|
||||
<svg
|
||||
:viewBox="`0 0 ${size} ${size}`"
|
||||
:width="size"
|
||||
:height="size * 0.7"
|
||||
class="overflow-visible"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient :id="uniqueId" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" :stop-color="colors.start" />
|
||||
<stop offset="50%" :stop-color="colors.mid" />
|
||||
<stop offset="100%" :stop-color="colors.end" />
|
||||
</linearGradient>
|
||||
<filter :id="`shadow-${uniqueId}`" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.35" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background arc (grey) -->
|
||||
<path
|
||||
:d="arcPath"
|
||||
fill="none"
|
||||
stroke="hsl(var(--muted))"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<!-- Colored gradient arc -->
|
||||
<path
|
||||
:d="filledArcPath"
|
||||
fill="none"
|
||||
:stroke="`url(#${uniqueId})`"
|
||||
:stroke-width="strokeWidth / 2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<!-- Indicator circle with shadow -->
|
||||
<g :filter="`url(#shadow-${uniqueId})`">
|
||||
<circle
|
||||
:cx="indicatorPos.x"
|
||||
:cy="indicatorPos.y"
|
||||
:r="10"
|
||||
fill="hsl(var(--muted))"
|
||||
/>
|
||||
<circle
|
||||
:cx="indicatorPos.x"
|
||||
:cy="indicatorPos.y"
|
||||
:r="8"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Speed number - large -->
|
||||
<text
|
||||
:x="center"
|
||||
:y="center"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
class="fill-primary font-bold"
|
||||
:style="{ fontSize: `${size * 0.35}px`, letterSpacing: '-0.05em' }"
|
||||
>
|
||||
{{ speedNumber }}
|
||||
</text>
|
||||
|
||||
<!-- Speed unit - small -->
|
||||
<text
|
||||
:x="center"
|
||||
:y="center + size * 0.20"
|
||||
text-anchor="middle"
|
||||
class="fill-muted-foreground font-medium"
|
||||
:style="{ fontSize: `${size * 0.12}px` }"
|
||||
>
|
||||
{{ speedUnit }}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<script setup lang="ts">
|
||||
import type { Component } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import Header from "@/components/layout/Header.vue";
|
||||
import Footer from "@/components/layout/Footer.vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight, Phone } from "lucide-vue-next";
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description: string;
|
||||
icon?: Component;
|
||||
heroImage?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-targo-dark text-white selection:bg-targo-green selection:text-white">
|
||||
<Header />
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="relative pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden">
|
||||
<!-- Background Elements -->
|
||||
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_var(--tw-gradient-stops))] from-targo-green/20 via-targo-dark to-targo-dark" />
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-[url('/grid-pattern.svg')] opacity-5" />
|
||||
|
||||
<div class="container relative mx-auto px-4">
|
||||
<div
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 30 }"
|
||||
:enter="{ opacity: 1, y: 0, transition: { duration: 800, ease: 'easeOut' } }"
|
||||
class="max-w-4xl mx-auto text-center"
|
||||
>
|
||||
<RouterLink
|
||||
to="/business"
|
||||
class="inline-flex items-center text-targo-green-light hover:text-white transition-colors mb-8 text-sm font-medium tracking-wide uppercase"
|
||||
>
|
||||
← Retour aux solutions affaires
|
||||
</RouterLink>
|
||||
|
||||
<div
|
||||
v-if="icon"
|
||||
v-motion
|
||||
:initial="{ scale: 0.8, opacity: 0 }"
|
||||
:enter="{ scale: 1, opacity: 1, transition: { delay: 200, duration: 500 } }"
|
||||
class="w-20 h-20 bg-targo-green/10 rounded-2xl flex items-center justify-center mx-auto mb-8 border border-targo-green/20 backdrop-blur-sm shadow-[0_0_30px_rgba(0,200,83,0.2)]"
|
||||
>
|
||||
<component :is="icon" class="h-10 w-10 text-targo-green" />
|
||||
</div>
|
||||
|
||||
<h1 class="text-5xl md:text-6xl lg:text-7xl font-bold mb-6 font-display tracking-tight">
|
||||
{{ title }} <span class="text-transparent bg-clip-text bg-gradient-to-r from-targo-green to-targo-green-light">{{ subtitle }}</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-lg md:text-xl text-gray-400 mb-10 max-w-3xl mx-auto leading-relaxed">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:enter="{ opacity: 1, y: 0, transition: { delay: 400, duration: 500 } }"
|
||||
class="flex flex-col sm:flex-row gap-4 justify-center"
|
||||
>
|
||||
<Button size="lg" class="bg-targo-green hover:bg-targo-green-dark text-white font-bold text-lg px-8 py-6 rounded-xl shadow-lg shadow-targo-green/20 transition-all hover:scale-105">
|
||||
Demander une soumission
|
||||
<ArrowRight class="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" class="text-lg px-8 py-6 border-white/10 bg-white/5 text-white hover:bg-white/10 hover:text-white rounded-xl backdrop-blur-sm transition-all hover:scale-105">
|
||||
<Phone class="mr-2 h-5 w-5" />
|
||||
1-855-888-2746
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<slot />
|
||||
|
||||
<div class="border-t border-white/5">
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
93
apps/website-vue/src/components/layout/Footer.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<script setup lang="ts">
|
||||
import { Phone, Mail, MapPin } from "lucide-vue-next";
|
||||
import targoLogo from "@/assets/targo-logo.png";
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="bg-targo-dark text-white pt-24 pb-12 border-t border-white/5 relative overflow-hidden">
|
||||
<!-- Decorative background element -->
|
||||
<div class="absolute top-0 right-0 w-[500px] h-[500px] bg-targo-green/5 rounded-full blur-[120px] -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
|
||||
<div class="container">
|
||||
<div class="grid md:grid-cols-4 gap-12 mb-12">
|
||||
<!-- Brand -->
|
||||
<div class="md:col-span-1 space-y-6">
|
||||
<img :src="targoLogo" alt="Targo - 100% Fibre" class="h-10 mb-2 brightness-0 invert" />
|
||||
<p class="text-white/60 text-sm leading-relaxed max-w-xs">La puissance de la fibre locale.
|
||||
La fiabilité, la performance et l'expertise à proximité pour propulser vos communications.</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick links -->
|
||||
<div>
|
||||
<h4 class="font-display font-bold mb-6 text-lg">Services</h4>
|
||||
<ul class="space-y-3 text-sm text-white/60">
|
||||
<li><a href="/internet" class="hover:text-targo-green transition-colors">Internet</a></li>
|
||||
<li><a href="/television" class="hover:text-targo-green transition-colors">Télévision</a></li>
|
||||
<li><a href="/telephone" class="hover:text-targo-green transition-colors">Téléphone</a></li>
|
||||
<li><a href="/support" class="hover:text-targo-green transition-colors">Support</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Company -->
|
||||
<div>
|
||||
<h4 class="font-display font-bold mb-6 text-lg">Plus</h4>
|
||||
<ul class="space-y-3 text-sm text-white/60">
|
||||
<li><a href="#apropos" class="hover:text-targo-green transition-colors">À propos</a></li>
|
||||
<li><a href="#contact" class="hover:text-targo-green transition-colors">Contact</a></li>
|
||||
<li><a href="https://store.targo.ca/clients" class="hover:text-targo-green transition-colors">Mon compte</a></li>
|
||||
<li><a href="/politique-de-confidentialite" class="hover:text-targo-green transition-colors">Politique de confidentialité</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Contact -->
|
||||
<div>
|
||||
<h4 class="font-sans font-bold mb-8 text-white uppercase tracking-widest text-xs">Nous joindre</h4>
|
||||
<ul class="space-y-5 text-sm">
|
||||
<li class="flex items-center gap-4 group">
|
||||
<div class="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center group-hover:bg-targo-green group-hover:text-white transition-all duration-300">
|
||||
<Phone class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-white/50 text-[10px] uppercase font-bold tracking-wider">Appelez-nous</span>
|
||||
<span class="text-white/80 font-medium group-hover:text-targo-green transition-colors">514-448-0773</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-center gap-4 group">
|
||||
<div class="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center group-hover:bg-targo-green group-hover:text-white transition-all duration-300">
|
||||
<Mail class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-white/50 text-[10px] uppercase font-bold tracking-wider">Écrivez-nous</span>
|
||||
<span class="text-white/80 font-medium group-hover:text-targo-green transition-colors">support@targo.ca</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start gap-4 group">
|
||||
<div class="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center group-hover:bg-targo-green group-hover:text-white transition-all duration-300 mt-1">
|
||||
<MapPin class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-white/50 text-[10px] uppercase font-bold tracking-wider">Visitez-nous</span>
|
||||
<span class="text-white/80 font-medium leading-relaxed group-hover:text-targo-green transition-colors">
|
||||
1867 chemin de la rivière<br />Ste-Clotilde, Québec
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div class="pt-12 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-6">
|
||||
<p class="text-xs text-white/40 font-medium">
|
||||
© {{ currentYear }} TARGO Communications. Tous droits réservés.
|
||||
</p>
|
||||
<div class="flex items-center gap-8 text-xs font-semibold uppercase tracking-wider text-white/40">
|
||||
<a href="/accessibilite" class="hover:text-targo-green transition-all">Plan d'accessibilité</a>
|
||||
<a href="/politique-de-confidentialite" class="hover:text-targo-green transition-all">Conditions d'utilisation</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
369
apps/website-vue/src/components/layout/Header.vue
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import {
|
||||
Menu, X, Phone, Mail, ChevronDown, Search, User,
|
||||
Wifi, PhoneCall, Building2, Signal, Server, Network,
|
||||
Cable, Cloud, Shield, Settings, Laptop, Globe,
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import SearchDialog from "@/components/SearchDialog.vue";
|
||||
import targoLogo from "@/assets/targo-logo.png";
|
||||
|
||||
// Business menu items with icons and descriptions
|
||||
const businessMenuItems = [
|
||||
{
|
||||
category: "CONNECTIVITÉ",
|
||||
items: [
|
||||
{ label: "Internet affaires", href: "/business/internet", icon: Globe, description: "Connexion fibre haute vitesse pour entreprises" },
|
||||
{ label: "SD-WAN", href: "/business/sd-wan", icon: Network, description: "Réseau intelligent multi-sites" },
|
||||
{ label: "Réseaux inter-sites", href: "/business/reseau-inter-sites", icon: Cable, description: "Liaisons dédiées entre vos succursales" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "COMMUNICATIONS",
|
||||
items: [
|
||||
{ label: "Téléphonie IP", href: "/business/telephonie", icon: PhoneCall, description: "Système téléphonique cloud complet" },
|
||||
{ label: "Liaisons SIP", href: "/business/sip", icon: Phone, description: "Trunks SIP pour votre PBX" },
|
||||
{ label: "Amplification cellulaire", href: "/business/amplification-cellulaire", icon: Signal, description: "Signal mobile amplifié partout" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "INFRASTRUCTURE",
|
||||
items: [
|
||||
{ label: "Services cloud", href: "/business/cloud", icon: Cloud, description: "Hébergement et sauvegarde sécurisés" },
|
||||
{ label: "Virtualisation", href: "/business/virtualisation", icon: Server, description: "Infrastructure virtualisée sur mesure" },
|
||||
{ label: "Microsoft 365", href: "/business/microsoft-365", icon: Laptop, description: "Suite Microsoft pour entreprises" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "SERVICES",
|
||||
items: [
|
||||
{ label: "WiFi commercial", href: "/business/wifi", icon: Wifi, description: "WiFi haute densité pour commerces" },
|
||||
{ label: "Câblage structuré", href: "/business/cablage", icon: Cable, description: "Installation réseau professionnelle" },
|
||||
{ label: "Services gérés", href: "/business/services-geres", icon: Settings, description: "Gestion TI complète externalisée" },
|
||||
{ label: "Sécurité DDoS", href: "/business/securite", icon: Shield, description: "Protection contre les cyberattaques" },
|
||||
{ label: "Multilogement", href: "/business/multilogement", icon: Building2, description: "Solutions pour immeubles résidentiels" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface NavLink {
|
||||
label: string;
|
||||
href: string;
|
||||
isMegaMenu?: boolean;
|
||||
submenu?: { label: string; href: string }[];
|
||||
}
|
||||
|
||||
const navLinks: NavLink[] = [
|
||||
{ label: "Internet", href: "/internet" },
|
||||
{ label: "Télévision", href: "/television" },
|
||||
{ label: "Téléphone", href: "/telephone" },
|
||||
{ label: "Affaires", href: "/business", isMegaMenu: true },
|
||||
{
|
||||
label: "À propos",
|
||||
href: "/support",
|
||||
submenu: [
|
||||
{ label: "Actualités", href: "/actualites" },
|
||||
{ label: "Support", href: "/support" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const businessMenuItemsFlat = businessMenuItems.flatMap((cat) => cat.items);
|
||||
|
||||
const isMenuOpen = ref(false);
|
||||
const isSearchOpen = ref(false);
|
||||
const isScrolled = ref(false);
|
||||
|
||||
const handleScroll = () => {
|
||||
isScrolled.value = window.scrollY > 20;
|
||||
};
|
||||
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
isSearchOpen.value = !isSearchOpen.value;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
document.addEventListener("keydown", down);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
document.removeEventListener("keydown", down);
|
||||
document.body.style.overflow = "";
|
||||
});
|
||||
|
||||
// Lock body scroll when mobile menu is open
|
||||
watch(isMenuOpen, (open) => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header :class="`fixed top-0 left-0 right-0 z-50 transition-all duration-500 ${isScrolled ? 'bg-white/90 backdrop-blur-xl shadow-2xl py-0' : 'bg-white py-0'}`">
|
||||
<SearchDialog v-model:open="isSearchOpen" />
|
||||
<!-- Top bar -->
|
||||
<div
|
||||
v-motion
|
||||
:animate="{ height: isScrolled ? 0 : 'auto', opacity: isScrolled ? 0 : 1, transition: { duration: 300 } }"
|
||||
class="bg-targo-green text-white py-2 px-4 shadow-md z-50 relative overflow-hidden hidden md:block"
|
||||
>
|
||||
<div class="container flex items-center justify-between text-xs font-semibold uppercase tracking-wider">
|
||||
<div class="flex items-center gap-8">
|
||||
<a href="tel:514.448.0773" class="flex items-center gap-2 hover:text-white/80 transition-colors">
|
||||
<Phone class="w-3.5 h-3.5" />
|
||||
<span>514.448.0773</span>
|
||||
</a>
|
||||
<a href="mailto:support@targo.ca" class="flex items-center gap-2 hover:text-white/80 transition-colors">
|
||||
<Mail class="w-3.5 h-3.5" />
|
||||
<span>support@targo.ca</span>
|
||||
</a>
|
||||
</div>
|
||||
<span class="opacity-90">La puissance de la fibre locale</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main nav -->
|
||||
<nav :class="`container py-4 transition-all duration-300 ${isScrolled ? 'py-2' : 'py-4'}`">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Logo -->
|
||||
<RouterLink to="/" class="flex items-center group">
|
||||
<img
|
||||
:src="targoLogo"
|
||||
alt="Targo - 100% Fibre"
|
||||
class="transition-transform group-hover:scale-105 flex-shrink-0 object-contain"
|
||||
:style="{ height: isScrolled ? '32px' : '40px', width: 'auto' }"
|
||||
/>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Desktop nav -->
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
<template v-for="link in navLinks" :key="link.href">
|
||||
<Popover v-if="link.isMegaMenu">
|
||||
<PopoverTrigger class="flex items-center gap-1 text-gray-700 hover:text-targo-green transition-colors font-medium text-sm lg:text-base outline-none">
|
||||
{{ link.label }}
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
class="w-[800px] p-6 bg-white border-gray-100 shadow-2xl rounded-xl"
|
||||
align="center"
|
||||
:side-offset="20"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-6">
|
||||
<div v-for="category in businessMenuItems" :key="category.category" class="space-y-3">
|
||||
<h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider">
|
||||
{{ category.category }}
|
||||
</h4>
|
||||
<div class="space-y-1">
|
||||
<RouterLink
|
||||
v-for="item in category.items"
|
||||
:key="item.href"
|
||||
:to="item.href"
|
||||
class="group flex items-start gap-3 p-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-md bg-targo-green/10 flex items-center justify-center flex-shrink-0 group-hover:bg-targo-green/20 transition-colors">
|
||||
<component :is="item.icon" class="w-4 h-4 text-targo-green" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-gray-900 text-sm group-hover:text-targo-green transition-colors">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 line-clamp-2">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 pt-4 border-t border-gray-100">
|
||||
<RouterLink
|
||||
to="/business"
|
||||
class="inline-flex items-center text-sm font-medium text-targo-green hover:text-targo-green/80 transition-colors"
|
||||
>
|
||||
Voir toutes les solutions affaires
|
||||
<ChevronDown class="w-4 h-4 ml-1 rotate-[-90deg]" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DropdownMenu v-else-if="link.submenu">
|
||||
<DropdownMenuTrigger class="flex items-center gap-1 text-gray-700 hover:text-targo-green transition-colors font-medium text-sm lg:text-base outline-none">
|
||||
{{ link.label }}
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="bg-white border-gray-100 text-gray-800 shadow-xl">
|
||||
<DropdownMenuItem
|
||||
v-for="sublink in link.submenu"
|
||||
:key="sublink.href"
|
||||
class="focus:bg-gray-50 focus:text-targo-green cursor-pointer"
|
||||
as-child
|
||||
>
|
||||
<RouterLink :to="sublink.href" class="w-full">
|
||||
{{ sublink.label }}
|
||||
</RouterLink>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<RouterLink
|
||||
v-else
|
||||
:to="link.href"
|
||||
class="text-gray-700 hover:text-targo-green transition-colors font-medium text-sm lg:text-base"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-gray-700 hover:text-black hover:bg-black/5"
|
||||
@click="isSearchOpen = true"
|
||||
>
|
||||
<Search class="w-5 h-5" />
|
||||
</Button>
|
||||
<Button variant="outline" class="border-gray-200 text-gray-700 bg-transparent hover:bg-gray-100 hover:text-black backdrop-blur-sm" as-child>
|
||||
<a href="https://store.targo.ca/clients" target="_blank" rel="noopener noreferrer">Mon compte</a>
|
||||
</Button>
|
||||
<Button class="gradient-targo text-white shadow-lg shadow-targo-green/20 glow-targo border-none" as-child>
|
||||
<a href="/#contact">Nous joindre</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<div class="flex items-center gap-2 md:hidden">
|
||||
<button
|
||||
class="p-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
aria-label="Search"
|
||||
@click="isSearchOpen = true"
|
||||
>
|
||||
<Search class="w-6 h-6" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
@click="isMenuOpen = !isMenuOpen"
|
||||
>
|
||||
<X v-if="isMenuOpen" class="w-6 h-6" />
|
||||
<Menu v-else class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<Transition>
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
v-motion
|
||||
:initial="{ height: 0, opacity: 0 }"
|
||||
:enter="{ height: '100vh', opacity: 1 }"
|
||||
:leave="{ height: 0, opacity: 0 }"
|
||||
class="md:hidden overflow-y-auto bg-white absolute left-0 right-0 top-full px-6 shadow-2xl border-t border-gray-100 z-50"
|
||||
>
|
||||
<div class="flex flex-col gap-6 py-10">
|
||||
<div
|
||||
v-for="(link, idx) in navLinks"
|
||||
:key="link.href"
|
||||
v-motion
|
||||
:initial="{ x: -20, opacity: 0 }"
|
||||
:enter="{ x: 0, opacity: 1, transition: { delay: idx * 50 } }"
|
||||
>
|
||||
<div v-if="link.isMegaMenu" class="flex flex-col gap-4">
|
||||
<span class="text-gray-900 font-bold text-lg">{{ link.label }}</span>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<RouterLink
|
||||
v-for="item in businessMenuItemsFlat"
|
||||
:key="item.href"
|
||||
:to="item.href"
|
||||
class="flex items-center gap-2 text-gray-600 hover:text-targo-green transition-colors text-sm"
|
||||
@click="isMenuOpen = false"
|
||||
>
|
||||
<component :is="item.icon" class="w-4 h-4 text-targo-green" />
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="link.submenu" class="flex flex-col gap-4">
|
||||
<span class="text-gray-900 font-bold text-lg">{{ link.label }}</span>
|
||||
<div class="pl-4 flex flex-col gap-4 py-2 border-l-2 border-targo-green/20">
|
||||
<RouterLink
|
||||
v-for="sublink in link.submenu"
|
||||
:key="sublink.href"
|
||||
:to="sublink.href"
|
||||
class="text-gray-600 hover:text-targo-green transition-colors text-base"
|
||||
@click="isMenuOpen = false"
|
||||
>
|
||||
{{ sublink.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink
|
||||
v-else
|
||||
:to="link.href"
|
||||
class="text-gray-900 hover:text-targo-green transition-colors font-bold text-lg"
|
||||
@click="isMenuOpen = false"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-motion
|
||||
:initial="{ y: 20, opacity: 0 }"
|
||||
:enter="{ y: 0, opacity: 1, transition: { delay: 300 } }"
|
||||
class="flex flex-col gap-4 pt-10 border-t border-gray-100"
|
||||
>
|
||||
<Button variant="outline" class="w-full border-gray-200 text-gray-700 hover:bg-gray-50 bg-white h-14 text-lg font-semibold shadow-sm" as-child>
|
||||
<a href="https://store.targo.ca/clients" target="_blank" rel="noopener noreferrer">
|
||||
<User class="w-5 h-5 mr-2" />
|
||||
Mon compte
|
||||
</a>
|
||||
</Button>
|
||||
<Button class="w-full gradient-targo text-white h-14 text-lg font-bold glow-targo border-none" as-child>
|
||||
<a href="/#contact">Nous joindre</a>
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-3 mt-4 text-gray-500">
|
||||
<a href="tel:514.448.0773" class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-targo-green/10 flex items-center justify-center">
|
||||
<Phone class="w-5 h-5 text-targo-green" />
|
||||
</div>
|
||||
<span class="font-semibold text-gray-900">514.448.0773</span>
|
||||
</a>
|
||||
<a href="mailto:support@targo.ca" class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-targo-green/10 flex items-center justify-center">
|
||||
<Mail class="w-5 h-5 text-targo-green" />
|
||||
</div>
|
||||
<span class="font-semibold text-gray-900">support@targo.ca</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
83
apps/website-vue/src/components/sections/About.vue
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<script setup lang="ts">
|
||||
import { Users, MapPin, Building2, Heart } from "lucide-vue-next";
|
||||
import targoBureaux from "@/assets/targo-bureaux.jpg";
|
||||
|
||||
const features = [{
|
||||
icon: MapPin,
|
||||
title: "Fibre 100% locale",
|
||||
description: "Un réseau fibre optique indépendant, conçu et géré localement pour notre communauté."
|
||||
}, {
|
||||
icon: Users,
|
||||
title: "Service personnalisé",
|
||||
description: "Une équipe dévouée qui connaît chaque client. Un service humain, proche de vous."
|
||||
}, {
|
||||
icon: Building2,
|
||||
title: "Économie locale",
|
||||
description: "Nous créons des emplois de qualité et soutenons les organismes et écoles de la région."
|
||||
}, {
|
||||
icon: Heart,
|
||||
title: "Engagement communautaire",
|
||||
description: "Fiers partenaires des événements locaux, nous redonnons à notre communauté."
|
||||
}];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="apropos" class="py-20 md:py-32">
|
||||
<div class="container">
|
||||
<div class="grid lg:grid-cols-2 gap-16 items-center">
|
||||
<!-- Image/Visual side -->
|
||||
<div class="relative">
|
||||
<div class="relative rounded-3xl overflow-hidden aspect-[4/3]">
|
||||
<img
|
||||
:src="targoBureaux"
|
||||
alt="Bureaux TARGO à Sainte-Clotilde"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stats card -->
|
||||
<div class="absolute -bottom-6 -left-6 bg-card p-6 rounded-2xl shadow-xl border border-border">
|
||||
<div class="font-display text-4xl font-bold text-primary mb-1">Ste-Clotilde</div>
|
||||
<div class="text-muted-foreground">Québec, Canada</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content side -->
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<span class="inline-block px-4 py-1 bg-primary/10 rounded-full text-primary font-medium text-sm mb-4">
|
||||
À propos
|
||||
</span>
|
||||
<h2 class="font-display text-4xl md:text-5xl font-bold mb-6">
|
||||
100% fibre <span class="text-gradient-targo">locale</span>
|
||||
</h2>
|
||||
<p class="text-muted-foreground text-lg mb-4">
|
||||
Fondée il y a 20 ans par un citoyen local, TARGO est née pour connecter les citoyens d'ici,
|
||||
à une époque où les grands fournisseurs ne s'y intéressaient pas. Au fil des années, nous avons
|
||||
branché des milliers de familles et d'entreprises de la région, en offrant une connexion fiable
|
||||
et des prix stables.
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
En choisissant TARGO, vous soutenez un service local qui réinvestit chaque année plus de
|
||||
100 000 $ dans la communauté et favorise l'embauche de talents d'ici, incluant des emplois
|
||||
d'été et des postes liés à l'innovation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features grid -->
|
||||
<div class="grid sm:grid-cols-2 gap-6 pt-4">
|
||||
<div v-for="feature in features" :key="feature.title" class="flex gap-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<component :is="feature.icon" class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-display font-semibold mb-1">{{ feature.title }}</h3>
|
||||
<p class="text-sm text-muted-foreground">{{ feature.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
35
apps/website-vue/src/components/sections/Clients.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
const clients = [
|
||||
"La Prairie",
|
||||
"Parc Safari",
|
||||
"Vegpro Int'l",
|
||||
"IGA",
|
||||
"Sobeys",
|
||||
"ASFC Canada",
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="py-16 border-y border-border bg-muted/30">
|
||||
<div class="container">
|
||||
<div class="text-center mb-12">
|
||||
<h3 class="font-display text-2xl font-bold mb-2">
|
||||
Des milliers de clients font notre référence
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
TARGO est devenue incontournable pour les communications de plusieurs milliers de clients
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center items-center gap-8 md:gap-16">
|
||||
<div
|
||||
v-for="client in clients"
|
||||
:key="client"
|
||||
class="px-6 py-3 bg-card rounded-lg border border-border text-muted-foreground font-medium hover:text-primary hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{{ client }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
188
apps/website-vue/src/components/sections/Contact.vue
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<script setup lang="ts">
|
||||
import { Phone, Mail, MapPin, Clock, Send } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ref } from "vue";
|
||||
import { useToast } from "@/components/ui/toast/use-toast";
|
||||
|
||||
const contactInfo = [
|
||||
{
|
||||
icon: Phone,
|
||||
label: "Téléphone",
|
||||
value: "1-855-88-TARGO",
|
||||
subValue: "514-448-0773",
|
||||
},
|
||||
{
|
||||
icon: Mail,
|
||||
label: "Courriel",
|
||||
value: "support@targo.ca",
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
label: "Adresse",
|
||||
value: "1867 chemin de la rivière",
|
||||
subValue: "Ste-Clotilde, Québec J0L 1W0",
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
label: "Heures d'ouverture",
|
||||
value: "Lun-Ven: 8h00 – 20h00",
|
||||
subValue: "Sam-Dim: 8h00 – 16h00",
|
||||
},
|
||||
];
|
||||
|
||||
const { toast } = useToast();
|
||||
const formData = ref({
|
||||
name: "",
|
||||
address: "",
|
||||
postalCode: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const sending = ref(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
sending.value = true;
|
||||
try {
|
||||
await fetch("/rpc/contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(formData.value),
|
||||
});
|
||||
toast({
|
||||
title: "Message envoyé!",
|
||||
description: "Nous vous répondrons dans les plus brefs délais.",
|
||||
});
|
||||
formData.value = { name: "", address: "", postalCode: "", email: "", phone: "", message: "" };
|
||||
} catch {
|
||||
toast({ title: "Erreur", description: "Impossible d'envoyer. Réessayez ou appelez-nous.", variant: "destructive" });
|
||||
}
|
||||
sending.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="contact" class="py-20 md:py-32 bg-secondary/30">
|
||||
<div class="container">
|
||||
<div class="text-center max-w-2xl mx-auto mb-16">
|
||||
<span class="inline-block px-4 py-1 bg-primary/10 rounded-full text-primary font-medium text-sm mb-4">
|
||||
Contactez-nous
|
||||
</span>
|
||||
<h2 class="font-display text-4xl md:text-5xl font-bold mb-4">
|
||||
On aime <span class="text-gradient-targo">aider</span>
|
||||
</h2>
|
||||
<p class="text-muted-foreground text-lg">
|
||||
Nous vous invitons à nous contacter pour bien évaluer votre situation
|
||||
et vous faire profiter des meilleurs forfaits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-5 gap-12">
|
||||
<!-- Contact form -->
|
||||
<div class="lg:col-span-3">
|
||||
<form @submit.prevent="handleSubmit" class="bg-card rounded-3xl p-8 shadow-lg border border-border">
|
||||
<div class="grid sm:grid-cols-2 gap-6 mb-6">
|
||||
<div class="space-y-2">
|
||||
<label for="name" class="text-sm font-medium">Nom</label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Votre nom"
|
||||
v-model="formData.name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="email" class="text-sm font-medium">Courriel</label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="votre@courriel.com"
|
||||
v-model="formData.email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 gap-6 mb-6">
|
||||
<div class="space-y-2">
|
||||
<label for="address" class="text-sm font-medium">Adresse (où le service est livré)</label>
|
||||
<Input
|
||||
id="address"
|
||||
name="address"
|
||||
placeholder="123 rue Principale"
|
||||
v-model="formData.address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="postalCode" class="text-sm font-medium">Code postal</label>
|
||||
<Input
|
||||
id="postalCode"
|
||||
name="postalCode"
|
||||
placeholder="J0L 1W0"
|
||||
v-model="formData.postalCode"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 mb-6">
|
||||
<label for="phone" class="text-sm font-medium">Téléphone</label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
placeholder="514-000-0000"
|
||||
v-model="formData.phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 mb-6">
|
||||
<label for="message" class="text-sm font-medium">Message</label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
placeholder="Comment pouvons-nous vous aider?"
|
||||
:rows="4"
|
||||
v-model="formData.message"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="lg" class="w-full gradient-targo">
|
||||
<Send class="w-5 h-5 mr-2" />
|
||||
Envoyer
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Contact info -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<div v-for="item in contactInfo" :key="item.label" class="flex gap-4 p-4 bg-card rounded-2xl border border-border">
|
||||
<div class="w-12 h-12 rounded-xl gradient-targo flex items-center justify-center flex-shrink-0">
|
||||
<component :is="item.icon" class="w-6 h-6 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-muted-foreground mb-1">{{ item.label }}</div>
|
||||
<div class="font-medium">{{ item.value }}</div>
|
||||
<div v-if="item.subValue" class="text-sm text-muted-foreground">{{ item.subValue }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-primary/10 rounded-2xl">
|
||||
<div class="font-display font-semibold mb-2">Surveillance 24/7/365</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Notre équipe surveille le réseau et répond aux urgences 24 heures sur 24,
|
||||
7 jours sur 7, toute l'année.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
161
apps/website-vue/src/components/sections/Hero.vue
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ArrowRight, Shield, Zap, Clock } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import heroFamily from "@/assets/hero-family-3d.png";
|
||||
import AvailabilityDialog from "@/components/AvailabilityDialog.vue";
|
||||
|
||||
const availOpen = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="accueil" class="relative pt-48 pb-24 md:pt-56 md:pb-40 overflow-hidden bg-white">
|
||||
<!-- Decorative background gradients -->
|
||||
<div class="absolute top-0 left-1/4 w-[600px] h-[600px] bg-targo-green/5 rounded-full blur-[120px] -translate-y-1/2 pointer-events-none" />
|
||||
<div class="absolute bottom-0 right-0 w-[800px] h-[800px] bg-targo-green/5 rounded-full blur-[150px] translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
|
||||
|
||||
|
||||
<div class="container relative">
|
||||
<div class="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<!-- Content -->
|
||||
<div class="space-y-10 relative z-10">
|
||||
<div
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:enter="{ opacity: 1, y: 0, transition: { duration: 600 } }"
|
||||
class="inline-flex items-center gap-3 px-4 py-2 bg-targo-green/10 rounded-full text-targo-green-dark font-bold text-xs uppercase tracking-widest"
|
||||
>
|
||||
<div class="w-2 h-2 rounded-full bg-targo-green animate-pulse" />
|
||||
<span>Réseau 100% Fibre Optique Locale</span>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<h1
|
||||
v-motion
|
||||
:initial="{ opacity: 0, x: -30 }"
|
||||
:enter="{ opacity: 1, x: 0, transition: { duration: 800, delay: 200 } }"
|
||||
class="font-display text-6xl md:text-7xl lg:text-8xl font-black leading-[0.9] tracking-tighter text-foreground"
|
||||
>
|
||||
Le futur est
|
||||
<br />
|
||||
<span class="text-gradient-targo">branché ici.</span>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-motion
|
||||
:initial="{ opacity: 0 }"
|
||||
:enter="{ opacity: 1, transition: { duration: 1000, delay: 400 } }"
|
||||
class="text-xl text-muted-foreground max-w-lg leading-relaxed font-sans"
|
||||
>
|
||||
Vitesse Gigabit, fiabilité absolue et service régional.
|
||||
Découvrez la puissance de la fibre optique de nouvelle génération.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 30 }"
|
||||
:enter="{ opacity: 1, y: 0, transition: { duration: 600, delay: 600 } }"
|
||||
class="flex flex-col sm:flex-row gap-4"
|
||||
>
|
||||
<Button size="lg" class="gradient-targo text-lg px-8 py-7 glow-targo group h-16 sm:h-auto">
|
||||
Voir nos forfaits
|
||||
<ArrowRight class="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" @click="availOpen = true" class="text-lg px-8 py-7 border-2 border-border text-foreground hover:border-targo-green hover:text-targo-green hover:bg-targo-green/5 h-16 sm:h-auto transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
|
||||
Vérifier la disponibilité
|
||||
</Button>
|
||||
<AvailabilityDialog v-model:open="availOpen" />
|
||||
</div>
|
||||
|
||||
<!-- Trust indicators -->
|
||||
<div
|
||||
v-motion
|
||||
:initial="{ opacity: 0 }"
|
||||
:enter="{ opacity: 1, transition: { duration: 1000, delay: 800 } }"
|
||||
class="grid grid-cols-3 gap-8 pt-8 border-t border-border"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<div class="font-display text-4xl font-black text-foreground">20+</div>
|
||||
<div class="text-[10px] uppercase font-bold tracking-widest text-muted-foreground">Années d'expertise</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="font-display text-4xl font-black text-foreground">10k+</div>
|
||||
<div class="text-[10px] uppercase font-bold tracking-widest text-muted-foreground">Clients satisfaits</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="font-display text-4xl font-black text-foreground">7j/7</div>
|
||||
<div class="text-[10px] uppercase font-bold tracking-widest text-muted-foreground">Support Local</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visual -->
|
||||
<div class="relative hidden lg:block">
|
||||
<div
|
||||
v-motion
|
||||
:initial="{ opacity: 0, scale: 0.9, x: 50 }"
|
||||
:enter="{ opacity: 1, scale: 1, x: 0, transition: { duration: 1000, delay: 300 } }"
|
||||
class="relative w-full max-w-xl mx-auto"
|
||||
>
|
||||
<div class="relative">
|
||||
<img :src="heroFamily" alt="Famille heureuse profitant de la télévision et d'Internet" class="w-full h-auto object-cover" />
|
||||
</div>
|
||||
|
||||
<!-- Floating Elements (Glassmorphism) -->
|
||||
<div
|
||||
v-motion
|
||||
:initial="{ y: 0 }"
|
||||
:enter="{ y: [0, -10, 0], transition: { duration: 4000, repeat: Infinity, ease: 'easeInOut' } }"
|
||||
class="absolute -top-6 -left-6 glass-card px-6 py-4 rounded-3xl z-20"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-targo-green/20 flex items-center justify-center">
|
||||
<Zap class="w-5 h-5 text-targo-green" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Vitesse</div>
|
||||
<div class="font-display font-black text-foreground">Gigabit+</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-motion
|
||||
:initial="{ y: 0 }"
|
||||
:enter="{ y: [0, 10, 0], transition: { duration: 5000, repeat: Infinity, ease: 'easeInOut' } }"
|
||||
class="absolute top-1/2 -right-10 glass-card px-6 py-4 rounded-3xl z-20"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-targo-green/20 flex items-center justify-center">
|
||||
<Shield class="w-5 h-5 text-targo-green" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Réseau</div>
|
||||
<div class="font-display font-black text-foreground">Sécurisé</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-motion
|
||||
:initial="{ x: 0 }"
|
||||
:enter="{ x: [0, 5, 0], transition: { duration: 6000, repeat: Infinity, ease: 'easeInOut' } }"
|
||||
class="absolute -bottom-6 left-1/4 glass-card px-6 py-4 rounded-3xl z-20"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-targo-green/20 flex items-center justify-center">
|
||||
<Clock class="w-5 h-5 text-targo-green" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Support</div>
|
||||
<div class="font-display font-black text-foreground">Local 7j/7</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
75
apps/website-vue/src/components/sections/Pricing.vue
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<script setup lang="ts">
|
||||
import { CheckCircle, ArrowRight } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
interface PricingTier {
|
||||
name: string;
|
||||
price: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
popular?: boolean;
|
||||
promo?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<{
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
tiers: PricingTier[];
|
||||
}>(), {
|
||||
title: "Nos forfaits",
|
||||
subtitle: "Simple et transparent",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="py-16 md:py-24 bg-muted/30">
|
||||
<div class="container mx-auto px-4">
|
||||
<div v-if="title || subtitle" class="text-center max-w-2xl mx-auto mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-4 font-display">{{ title }}</h2>
|
||||
<p class="text-muted-foreground">{{ subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||
<Card
|
||||
v-for="(tier, index) in tiers"
|
||||
:key="index"
|
||||
:class="`relative ${tier.popular ? 'border-primary border-2 shadow-xl' : ''}`"
|
||||
>
|
||||
<span v-if="tier.popular" class="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground px-4 py-1 rounded-full text-sm font-medium">
|
||||
Recommandé
|
||||
</span>
|
||||
|
||||
<CardHeader class="text-center pt-8">
|
||||
<CardTitle class="text-2xl">{{ tier.name }}</CardTitle>
|
||||
<CardDescription>{{ tier.description }}</CardDescription>
|
||||
<div class="mt-4">
|
||||
<span class="text-4xl font-bold text-primary">{{ tier.price }}</span>
|
||||
<span class="text-muted-foreground text-sm">/mois</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<ul class="space-y-3 mb-6">
|
||||
<li v-for="(feature, i) in tier.features" :key="i" class="flex items-start gap-3 text-sm text-muted-foreground">
|
||||
<CheckCircle class="w-5 h-5 text-primary flex-shrink-0" />
|
||||
<span>{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
class="w-full"
|
||||
:variant="tier.popular ? 'default' : 'outline'"
|
||||
as-child
|
||||
>
|
||||
<a href="/#contact">Choisir ce forfait <ArrowRight class="w-4 h-4 ml-2" /></a>
|
||||
</Button>
|
||||
<p v-if="tier.promo" class="text-xs text-primary text-center font-medium mt-4">
|
||||
{{ tier.promo }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
142
apps/website-vue/src/components/sections/Services.vue
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<script setup lang="ts">
|
||||
import { Wifi, Tv, Phone, ArrowRight, Check } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { RouterLink } from "vue-router";
|
||||
|
||||
const services = [{
|
||||
icon: Wifi,
|
||||
title: "Internet",
|
||||
description: "La meilleure connexion sur la fibre",
|
||||
price: 39.95,
|
||||
unit: "/mois",
|
||||
features: ["Vitesse jusqu'à 1.5 Gbps", "Données illimitées", "WiFi et support inclus"],
|
||||
popular: true,
|
||||
link: "/internet"
|
||||
}, {
|
||||
icon: Tv,
|
||||
title: "Télévision",
|
||||
description: "La télé allumée par la fibre",
|
||||
price: 25,
|
||||
unit: "/mois +tx",
|
||||
features: ["80+ chaînes HD", "Récepteur fourni", "Enregistrement numérique"],
|
||||
popular: false,
|
||||
link: "/television"
|
||||
}, {
|
||||
icon: Phone,
|
||||
title: "Téléphone",
|
||||
description: "Ligne résidentielle, tout inclus",
|
||||
price: 10,
|
||||
unit: "/mois +tx",
|
||||
features: ["Appels illimités", "Canada et États-Unis", "Afficheur inclus"],
|
||||
popular: false,
|
||||
link: "/telephone"
|
||||
}];
|
||||
|
||||
const centsOf = (price: number) => ((price % 1) * 100).toFixed(0).padStart(2, "0");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="services" class="py-24 md:py-40 bg-gradient-to-br from-secondary via-background to-secondary relative overflow-hidden">
|
||||
<!-- Decorative background gradients -->
|
||||
<div class="absolute top-1/2 left-0 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] -translate-x-1/2 pointer-events-none" />
|
||||
|
||||
<div class="container">
|
||||
<!-- Section header -->
|
||||
<div class="text-center max-w-2xl mx-auto mb-16">
|
||||
<span
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:visible-once="{ opacity: 1, y: 0 }"
|
||||
class="inline-block px-4 py-1 bg-primary/10 rounded-full text-primary font-medium text-sm mb-4"
|
||||
>
|
||||
Nos solutions fibre
|
||||
</span>
|
||||
<h2
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:visible-once="{ opacity: 1, y: 0, transition: { delay: 100 } }"
|
||||
class="font-display text-4xl md:text-5xl font-bold mb-6"
|
||||
>
|
||||
Des forfaits pour <span class="text-gradient-targo">tous les besoins</span>
|
||||
</h2>
|
||||
<p
|
||||
v-motion
|
||||
:initial="{ opacity: 0 }"
|
||||
:visible-once="{ opacity: 1, transition: { delay: 200 } }"
|
||||
class="text-muted-foreground text-lg leading-relaxed"
|
||||
>
|
||||
Internet haute vitesse, télévision HD et téléphonie résidentielle.
|
||||
Le tout alimenté par notre réseau 100% fibre optique régional.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Service cards -->
|
||||
<div class="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||
<div
|
||||
v-for="(service, index) in services"
|
||||
:key="service.title"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 30 }"
|
||||
:visible-once="{ opacity: 1, y: 0, transition: { delay: index * 100 } }"
|
||||
>
|
||||
<Card :class="`relative overflow-hidden transition-all duration-300 hover:shadow-xl hover:-translate-y-1 ${
|
||||
service.popular ? 'border-primary shadow-lg ring-2 ring-primary/20' : ''
|
||||
}`">
|
||||
<div v-if="service.popular" class="absolute top-4 right-4 z-10 px-3 py-1 gradient-targo text-primary-foreground text-xs font-medium rounded-full">
|
||||
Populaire
|
||||
</div>
|
||||
|
||||
<CardContent class="p-8 flex flex-col h-full">
|
||||
<div :class="`w-14 h-14 rounded-2xl flex items-center justify-center mb-6 ${
|
||||
service.popular ? 'gradient-targo text-primary-foreground' : 'bg-primary/10 text-primary'
|
||||
}`">
|
||||
<component :is="service.icon" class="w-7 h-7" />
|
||||
</div>
|
||||
|
||||
<h3 class="font-display text-2xl font-bold mb-2">{{ service.title }}</h3>
|
||||
<p class="text-muted-foreground text-sm mb-6 min-h-[40px]">{{ service.description }}</p>
|
||||
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="font-display text-5xl font-bold text-primary">
|
||||
${{ Math.floor(service.price) }}
|
||||
<sup class="text-2xl">.{{ centsOf(service.price) }}</sup>
|
||||
</span>
|
||||
<span class="text-muted-foreground text-sm">{{ service.unit }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mb-4">à partir de</p>
|
||||
|
||||
<ul class="space-y-3 mb-8 flex-grow mt-4">
|
||||
<li v-for="feature in service.features" :key="feature" class="flex items-center gap-3">
|
||||
<div class="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Check class="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-auto">
|
||||
<Button
|
||||
as-child
|
||||
:class="`w-full ${service.popular ? 'gradient-targo' : ''}`"
|
||||
:variant="service.popular ? 'default' : 'outline'"
|
||||
>
|
||||
<RouterLink :to="service.link">
|
||||
Voir les détails
|
||||
<ArrowRight class="w-4 h-4 ml-2" />
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note -->
|
||||
<p class="text-center text-sm text-muted-foreground mt-12 max-w-2xl mx-auto">
|
||||
Les technologies et promotions disponibles varient parfois entre deux voisins.
|
||||
Contactez-nous pour évaluer votre situation et obtenir le meilleur forfait pour vous.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
26
apps/website-vue/src/components/ui/GlassCard.vue
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ class?: string; hoverEffect?: boolean }>(),
|
||||
{ hoverEffect: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:visible-once="{ opacity: 1, y: 0, transition: { duration: 500 } }"
|
||||
:hovered="props.hoverEffect ? { scale: 1.02, transition: { duration: 200 } } : {}"
|
||||
:class="cn(
|
||||
'relative overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-md shadow-xl',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent pointer-events-none" />
|
||||
<div class="relative z-10">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
49
apps/website-vue/src/components/ui/Logo.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{ class?: string; variant?: "default" | "white" }>(),
|
||||
{ class: "h-10", variant: "default" },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 400 120"
|
||||
:class="$props.class"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M40 20 H100 M70 20 V100"
|
||||
:stroke="variant === 'white' ? 'white' : '#00C853'"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M120 100 L150 20 L180 100 M135 70 H165"
|
||||
:stroke="variant === 'white' ? 'white' : '#00C853'"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M200 100 V20 H250 A30 30 0 0 1 250 80 H200 M250 80 L280 100"
|
||||
:stroke="variant === 'white' ? 'white' : '#00C853'"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M300 20 H360 V100 H300 V60 H350"
|
||||
:stroke="variant === 'white' ? 'white' : '#00C853'"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M380 60 A40 40 0 1 1 380 59"
|
||||
:stroke="variant === 'white' ? 'white' : '#00C853'"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
18
apps/website-vue/src/components/ui/accordion/Accordion.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import type { AccordionRootEmits, AccordionRootProps } from "reka-ui"
|
||||
import {
|
||||
AccordionRoot,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
const props = defineProps<AccordionRootProps>()
|
||||
const emits = defineEmits<AccordionRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</AccordionRoot>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { AccordionContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AccordionContent } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionContent
|
||||
v-bind="delegatedProps"
|
||||
class="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
>
|
||||
<div :class="cn('pb-4 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { AccordionItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AccordionItem, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionItem
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('border-b', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AccordionItem>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<script setup lang="ts">
|
||||
import type { AccordionTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronDown } from "lucide-vue-next"
|
||||
import {
|
||||
AccordionHeader,
|
||||
AccordionTrigger,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionHeader class="flex">
|
||||
<AccordionTrigger
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<slot name="icon">
|
||||
<ChevronDown
|
||||
class="h-4 w-4 shrink-0 transition-transform duration-200"
|
||||
/>
|
||||
</slot>
|
||||
</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
</template>
|
||||
4
apps/website-vue/src/components/ui/accordion/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { default as Accordion } from "./Accordion.vue"
|
||||
export { default as AccordionContent } from "./AccordionContent.vue"
|
||||
export { default as AccordionItem } from "./AccordionItem.vue"
|
||||
export { default as AccordionTrigger } from "./AccordionTrigger.vue"
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
|
||||
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogProps>()
|
||||
const emits = defineEmits<AlertDialogEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import type { AlertDialogActionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogAction } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import type { AlertDialogCancelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogCancel } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<script setup lang="ts">
|
||||
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<AlertDialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<AlertDialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { AlertDialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogDescription,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogDescription
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogDescription>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import type { AlertDialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogTitle } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTitle
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-lg font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogTitle>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { AlertDialogTriggerProps } from "reka-ui"
|
||||
import { AlertDialogTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTrigger v-bind="props">
|
||||
<slot />
|
||||
</AlertDialogTrigger>
|
||||
</template>
|
||||
9
apps/website-vue/src/components/ui/alert-dialog/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { default as AlertDialog } from "./AlertDialog.vue"
|
||||
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
|
||||
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
|
||||
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
|
||||
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
|
||||
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
|
||||
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
|
||||
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
|
||||
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
|
||||
17
apps/website-vue/src/components/ui/alert/Alert.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { AlertVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { alertVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: AlertVariants["variant"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
apps/website-vue/src/components/ui/alert/AlertTitle.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h5 :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
|
||||
<slot />
|
||||
</h5>
|
||||
</template>
|
||||
24
apps/website-vue/src/components/ui/alert/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Alert } from "./Alert.vue"
|
||||
export { default as AlertDescription } from "./AlertDescription.vue"
|
||||
export { default as AlertTitle } from "./AlertTitle.vue"
|
||||
|
||||
export const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { AspectRatioProps } from "reka-ui"
|
||||
import { AspectRatio } from "reka-ui"
|
||||
|
||||
const props = defineProps<AspectRatioProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AspectRatio v-bind="props">
|
||||
<slot />
|
||||
</AspectRatio>
|
||||
</template>
|
||||
1
apps/website-vue/src/components/ui/aspect-ratio/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as AspectRatio } from "./AspectRatio.vue"
|
||||
22
apps/website-vue/src/components/ui/avatar/Avatar.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { AvatarVariants } from "."
|
||||
import { AvatarRoot } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { avatarVariant } from "."
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
size?: AvatarVariants["size"]
|
||||
shape?: AvatarVariants["shape"]
|
||||
}>(), {
|
||||
size: "sm",
|
||||
shape: "circle",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarRoot :class="cn(avatarVariant({ size, shape }), props.class)">
|
||||
<slot />
|
||||
</AvatarRoot>
|
||||
</template>
|
||||
12
apps/website-vue/src/components/ui/avatar/AvatarFallback.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { AvatarFallbackProps } from "reka-ui"
|
||||
import { AvatarFallback } from "reka-ui"
|
||||
|
||||
const props = defineProps<AvatarFallbackProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarFallback v-bind="props">
|
||||
<slot />
|
||||
</AvatarFallback>
|
||||
</template>
|
||||
12
apps/website-vue/src/components/ui/avatar/AvatarImage.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { AvatarImageProps } from "reka-ui"
|
||||
import { AvatarImage } from "reka-ui"
|
||||
|
||||
const props = defineProps<AvatarImageProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarImage v-bind="props" class="h-full w-full object-cover">
|
||||
<slot />
|
||||
</AvatarImage>
|
||||
</template>
|
||||
25
apps/website-vue/src/components/ui/avatar/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Avatar } from "./Avatar.vue"
|
||||
export { default as AvatarFallback } from "./AvatarFallback.vue"
|
||||
export { default as AvatarImage } from "./AvatarImage.vue"
|
||||
|
||||
export const avatarVariant = cva(
|
||||
"inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-10 w-10 text-xs",
|
||||
base: "h-16 w-16 text-2xl",
|
||||
lg: "h-32 w-32 text-5xl",
|
||||
},
|
||||
shape: {
|
||||
circle: "rounded-full",
|
||||
square: "rounded-md",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AvatarVariants = VariantProps<typeof avatarVariant>
|
||||
17
apps/website-vue/src/components/ui/badge/Badge.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { BadgeVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { badgeVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
variant?: BadgeVariants["variant"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(badgeVariants({ variant }), props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
26
apps/website-vue/src/components/ui/badge/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Badge } from "./Badge.vue"
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex gap-1 items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
13
apps/website-vue/src/components/ui/breadcrumb/Breadcrumb.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav aria-label="breadcrumb" :class="props.class">
|
||||
<slot />
|
||||
</nav>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { MoreHorizontal } from "lucide-vue-next"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
:class="cn('flex h-9 w-9 items-center justify-center', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</slot>
|
||||
<span class="sr-only">More</span>
|
||||
</span>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
:class="cn('inline-flex items-center gap-1.5', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts" setup>
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>(), {
|
||||
as: "a",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn('transition-colors hover:text-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol
|
||||
:class="cn('flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ol>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
:class="cn('font-normal text-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { ChevronRight } from "lucide-vue-next"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
:class="cn('[&>svg]:w-3.5 [&>svg]:h-3.5', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<ChevronRight />
|
||||
</slot>
|
||||
</li>
|
||||
</template>
|
||||
7
apps/website-vue/src/components/ui/breadcrumb/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { default as Breadcrumb } from "./Breadcrumb.vue"
|
||||
export { default as BreadcrumbEllipsis } from "./BreadcrumbEllipsis.vue"
|
||||
export { default as BreadcrumbItem } from "./BreadcrumbItem.vue"
|
||||
export { default as BreadcrumbLink } from "./BreadcrumbLink.vue"
|
||||
export { default as BreadcrumbList } from "./BreadcrumbList.vue"
|
||||
export { default as BreadcrumbPage } from "./BreadcrumbPage.vue"
|
||||
export { default as BreadcrumbSeparator } from "./BreadcrumbSeparator.vue"
|
||||
28
apps/website-vue/src/components/ui/button/Button.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "."
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
37
apps/website-vue/src/components/ui/button/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Button } from "./Button.vue"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
"default": "h-10 px-4 py-2",
|
||||
"sm": "h-9 rounded-md px-3",
|
||||
"lg": "h-11 rounded-md px-8",
|
||||
"icon": "h-10 w-10",
|
||||
"icon-sm": "size-9",
|
||||
"icon-lg": "size-11",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
58
apps/website-vue/src/components/ui/calendar/Calendar.vue
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarRootEmits, CalendarRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from "."
|
||||
|
||||
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const emits = defineEmits<CalendarRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarRoot
|
||||
v-slot="{ grid, weekDays }"
|
||||
:class="cn('p-3', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<CalendarHeader>
|
||||
<CalendarPrevButton />
|
||||
<CalendarHeading />
|
||||
<CalendarNextButton />
|
||||
</CalendarHeader>
|
||||
|
||||
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
|
||||
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
|
||||
<CalendarGridHead>
|
||||
<CalendarGridRow>
|
||||
<CalendarHeadCell
|
||||
v-for="day in weekDays" :key="day"
|
||||
>
|
||||
{{ day }}
|
||||
</CalendarHeadCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridHead>
|
||||
<CalendarGridBody>
|
||||
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
|
||||
<CalendarCell
|
||||
v-for="weekDate in weekDates"
|
||||
:key="weekDate.toString()"
|
||||
:date="weekDate"
|
||||
>
|
||||
<CalendarCellTrigger
|
||||
:day="weekDate"
|
||||
:month="month.value"
|
||||
/>
|
||||
</CalendarCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridBody>
|
||||
</CalendarGrid>
|
||||
</div>
|
||||
</CalendarRoot>
|
||||
</template>
|
||||
22
apps/website-vue/src/components/ui/calendar/CalendarCell.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarCellProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarCell, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCell
|
||||
:class="cn('relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarCell>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarCellTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarCellTrigger, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCellTrigger
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal',
|
||||
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||
// Selected
|
||||
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
|
||||
// Disabled
|
||||
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||
// Unavailable
|
||||
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
|
||||
// Outside months
|
||||
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarCellTrigger>
|
||||
</template>
|
||||
22
apps/website-vue/src/components/ui/calendar/CalendarGrid.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { CalendarGrid, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGrid
|
||||
:class="cn('w-full border-collapse space-y-1', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarGrid>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridBodyProps } from "reka-ui"
|
||||
import { CalendarGridBody } from "reka-ui"
|
||||
|
||||
const props = defineProps<CalendarGridBodyProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridBody v-bind="props">
|
||||
<slot />
|
||||
</CalendarGridBody>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CalendarGridHeadProps } from "reka-ui"
|
||||
import { CalendarGridHead } from "reka-ui"
|
||||
|
||||
const props = defineProps<CalendarGridHeadProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridHead v-bind="props">
|
||||
<slot />
|
||||
</CalendarGridHead>
|
||||
</template>
|
||||