Compare commits

...

4 Commits

Author SHA1 Message Date
louispaulb
0c5f33460a scripts(targo-sync): incremental invoice/service sync + daily tickets
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:52:14 -04:00
louispaulb
e950bd3ac6 hub: reverse geocoding infra-first (fibre field-pins -> RQA fallback); address resolution + geotagged field photos
- /address/reverse: reverseNearestFibre() (fibre field-pins, source of truth) then RQA
- searchCustomers: civic address resolves to the resident (not fuzzy homonyms)
- field-app + /field/photo: geotagged photos confirm the correct job

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:52:14 -04:00
louispaulb
042c5545f7 ops: MapLibre/OSM basemap (drop Mapbox) + satellite toggle, unified Créer flow, wizard pricing, infra-first reverse client
- Maps: MapLibre GL + self-hosted Protomaps tiles + ESRI satellite toggle
- FAB 'Créer' chooser (incl. Soumission wizard) via useCreateSignal
- Quote wizard: new default tiers (80/500/1500 @ 39.95/49.95/49.95), competitor
  price-match, mini-map pin w/ green check, carry qualified address
- api/address.reverse() + reverseGeocodeOSM tries RQA-backed hub, Nominatim fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:52:14 -04:00
louispaulb
3c7f04870c portal: Vue 3 + Tailwind + shadcn-vue rewrite of www.gigafibre.ca (preview at /next)
Pixel-identical port of the React marketing site: Tailwind config, HSL theme
tokens, fonts (Plus Jakarta Sans / Space Grotesk) and custom utilities copied
verbatim; shadcn-vue (reka-ui) components; all 33 routes ported. Served at
www.gigafibre.ca/next (noindex) behind nginx, ahead of replacing the React site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:52:14 -04:00
377 changed files with 22379 additions and 200 deletions

1
apps/ops/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
public/tiles/

View File

@ -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",

View File

@ -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",

View File

@ -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',

View File

@ -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))

View File

@ -11,6 +11,12 @@
<template v-if="issue?.name">{{ issue.name }} &middot; {{ 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;

View 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>

View File

@ -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. -->

View 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 } }
}

View File

@ -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 {

View File

@ -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,

View File

@ -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;

View 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) {} }

View File

@ -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' },
],
},
]

View File

@ -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)

View File

@ -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">&nbsp; 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">🗓&nbsp; Horaires &amp; 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">👁&nbsp; 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&nbsp;%</b> = normale · <b>200&nbsp;%</b> = deux fois plus de jobs · &lt;&nbsp;100&nbsp;% = 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 &amp; 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 816 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é &amp; 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é &amp; 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 &amp; 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 15} (JSON).
// Par compétence : SCORE (maîtrise 15, 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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) : supportcasque · installationéchelle · réparation/terrainhandyman.
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
View File

@ -0,0 +1,7 @@
node_modules/
dist/
dist-ssr/
*.local
.DS_Store
.vite/
*.log

View 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).

View 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
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

View 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

View File

@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View 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>

View 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>

View 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 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>

View 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>

View 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 ( 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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"

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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"

View 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>

View 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>
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
<slot />
</div>
</template>

View 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>

View 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>

View File

@ -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>

View File

@ -0,0 +1 @@
export { default as AspectRatio } from "./AspectRatio.vue"

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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"

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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>

Some files were not shown because too many files have changed in this diff Show More