diff --git a/package-lock.json b/package-lock.json index e92ffae..6b57621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", + "@gebeta/tiles": "^2.1.5", "@mui/icons-material": "7.3.5", "@mui/material": "7.3.5", "@popperjs/core": "2.11.8", @@ -48,6 +49,7 @@ "embla-carousel-react": "8.6.0", "input-otp": "1.4.2", "lucide-react": "0.487.0", + "maplibre-gl": "^5.24.0", "motion": "12.23.24", "next-themes": "0.4.6", "react-day-picker": "8.10.1", @@ -60,6 +62,7 @@ "react-router": "7.13.0", "react-slick": "0.31.0", "recharts": "2.15.2", + "socket.io-client": "^4.8.3", "sonner": "2.0.3", "tailwind-merge": "3.2.0", "tw-animate-css": "1.3.8", @@ -998,6 +1001,50 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@gebeta/tiles": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@gebeta/tiles/-/tiles-2.1.5.tgz", + "integrity": "sha512-THiY8QsQuRPFDfXV09kebDseeZvZa4bNakTsnF+ySICNM/fQbdQtz4Ik/zKm2gCk9e42STa4kOkq0X/fPUBYFw==", + "license": "MIT", + "dependencies": { + "maplibre-gl": "^5.6.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "supercluster": "^8.0.1" + }, + "peerDependencies": { + "maplibre-gl": ">=5", + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@gebeta/tiles/node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@gebeta/tiles/node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/@gebeta/tiles/node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "node_modules/@googlemaps/js-api-loader": { "version": "1.16.8", "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz", @@ -1073,6 +1120,119 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "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.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/@mui/core-downloads-tracker": { "version": "7.3.11", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.11.tgz", @@ -3003,6 +3163,12 @@ "win32" ] }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", @@ -3395,6 +3561,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/google.maps": { "version": "3.58.1", "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", @@ -3861,6 +4033,12 @@ "csstype": "^3.0.2" } }, + "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/electron-to-chromium": { "version": "1.5.361", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", @@ -3896,6 +4074,28 @@ "embla-carousel": "8.6.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", @@ -4107,6 +4307,12 @@ "node": ">=6" } }, + "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/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4240,6 +4446,12 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "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/json2mq": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", @@ -4566,6 +4778,49 @@ "@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/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -4636,6 +4891,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "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/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -4729,6 +4990,18 @@ "node": ">=8" } }, + "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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4777,6 +5050,12 @@ "node": "^10 || ^12 || >=14" } }, + "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/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4794,6 +5073,18 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "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/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/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -5164,6 +5455,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/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", @@ -5235,6 +5535,34 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/sonner": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.3.tgz", @@ -5378,6 +5706,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "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/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5586,6 +5920,35 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/src/app/components/DashboardLayout.tsx b/src/app/components/DashboardLayout.tsx index a1fab9a..64dc338 100644 --- a/src/app/components/DashboardLayout.tsx +++ b/src/app/components/DashboardLayout.tsx @@ -1,5 +1,5 @@ import { Outlet, useNavigate, useLocation } from 'react-router'; -import { LayoutDashboard, Car, Users, Wallet, Settings, Bell, User, LogOut, Shield, BarChart3, UserCog, PhoneCall, Moon, Sun } from 'lucide-react'; +import { LayoutDashboard, Car, Users, Wallet, Settings, Bell, User, LogOut, Shield, BarChart3, UserCog, PhoneCall, Moon, Sun, CheckCheck } from 'lucide-react'; import { Button } from '../components/ui/button'; import { DropdownMenu, @@ -17,7 +17,7 @@ import { SelectTrigger, SelectValue, } from '../components/ui/select'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { useAppContext } from '../contexts/AppContext'; import { useAuth } from '../contexts/AuthContext'; @@ -43,37 +43,109 @@ const adminNavigation = [ { nameKey: 'nav.settings', href: '/settings', icon: Settings }, ]; +interface NotificationItem { + id: string; + message: string; + time: Date; + read: boolean; + type: 'info' | 'success' | 'warning'; +} + export default function DashboardLayout() { const navigate = useNavigate(); const location = useLocation(); const { user, role: userRole, logout } = useAuth(); const userName = user?.name ?? 'User'; const { t, language, setLanguage, theme, setTheme } = useAppContext(); - const [notifications, setNotifications] = useState(0); - // Live dispatch notifications over WebSockets (centralized for the whole dashboard). + const [notifItems, setNotifItems] = useState([ + // Sample notification so admin can preview the UI + { + id: 'sample-1', + message: 'Welcome! Ride updates and status changes will appear here in real time.', + time: new Date(), + read: false, + type: 'info' as const, + }, + ]); + const [notifOpen, setNotifOpen] = useState(false); + const notifRef = useRef(null); + + const unreadCount = notifItems.filter((n) => !n.read).length; + + // Close on outside click or Esc + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setNotifOpen(false); }; + const onClick = (e: MouseEvent) => { + if (notifRef.current && !notifRef.current.contains(e.target as Node)) setNotifOpen(false); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('mousedown', onClick); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('mousedown', onClick); + }; + }, []); + + // Live dispatch notifications over WebSockets useEffect(() => { const socket = getSocket() ?? connectSocket(); const onStatus = (data: any) => { - setNotifications((n) => n + 1); const label = data?.statusLabel ?? rideStatusLabel(data?.status); - toast(`Ride ${String(data?.rideId ?? '').slice(0, 8)} → ${label}`); + const msg = `Ride ${String(data?.rideId ?? '').slice(0, 8)} → ${label}`; + toast(msg); + setNotifItems((prev) => [{ + id: `${Date.now()}-${Math.random()}`, + message: msg, + time: new Date(), + read: false, + type: 'info' as const, + }, ...prev].slice(0, 50)); }; + const onCompleted = (data: any) => { - setNotifications((n) => n + 1); - toast.success(`Ride completed · ${data?.fare ?? ''} ${data?.currency ?? 'ETB'}`); + const msg = `Ride completed · ${data?.fare ?? ''} ${data?.currency ?? 'ETB'}`; + toast.success(msg); + setNotifItems((prev) => [{ + id: `${Date.now()}-${Math.random()}`, + message: msg, + time: new Date(), + read: false, + type: 'success' as const, + }, ...prev].slice(0, 50)); }; socket.on('ride:status', onStatus); socket.on('ride:completed', onCompleted); - return () => { socket.off('ride:status', onStatus); socket.off('ride:completed', onCompleted); }; }, []); + const markRead = (id: string) => { + setNotifItems((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n)); + }; + + const markAllRead = () => { + setNotifItems((prev) => prev.map((n) => ({ ...n, read: true }))); + }; + + const formatTime = (d: Date) => { + const diff = Math.floor((Date.now() - d.getTime()) / 1000); + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return d.toLocaleDateString(); + }; + + const typeColor = (type: NotificationItem['type']) => ({ + info: 'bg-[#00BDC3]', + success: 'bg-[#10B981]', + warning: 'bg-[#F59E0B]', + })[type]; + const navigation = userRole === 'admin' ? adminNavigation : operatorNavigation; const handleLogout = () => { @@ -85,7 +157,6 @@ export default function DashboardLayout() {
{/* Sidebar */}
- My Account + {t('common.account', 'My Account')} - Profile + {t('common.profile', 'Profile')} - + navigate('/settings')}> - Settings + {t('nav.settings', 'Settings')} - Logout + {t('common.logout', 'Logout')} @@ -230,4 +369,4 @@ export default function DashboardLayout() { ); -} \ No newline at end of file +} diff --git a/src/app/components/LogCallDialog.tsx b/src/app/components/LogCallDialog.tsx index 5f62c11..5bb7b8e 100644 --- a/src/app/components/LogCallDialog.tsx +++ b/src/app/components/LogCallDialog.tsx @@ -7,11 +7,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { PhoneCall, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '../lib/api'; +import { useAppContext } from '../contexts/AppContext'; const empty = { customerPhone: '', customerName: '', durationMinutes: '', status: 'completed', notes: '' }; /** Lets an operator record a call (esp. missed/abandoned ones that don't become rides). */ export default function LogCallDialog({ onLogged, className }: { onLogged?: () => void; className?: string }) { + const { t } = useAppContext(); const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [form, setForm] = useState(empty); @@ -46,34 +48,34 @@ export default function LogCallDialog({ onLogged, className }: { onLogged?: () = - Log a Call + {t('dashboard.logCallTitle', 'Log a Call')}
-
setForm({ ...form, customerPhone: e.target.value })} placeholder="0911 234567" />
-
setForm({ ...form, customerName: e.target.value })} placeholder="Optional" />
+
setForm({ ...form, customerPhone: e.target.value })} placeholder="0911 234567" />
+
setForm({ ...form, customerName: e.target.value })} placeholder={t('common.optional', 'Optional')} />
-
setForm({ ...form, durationMinutes: e.target.value })} placeholder="0" />
+
setForm({ ...form, durationMinutes: e.target.value })} placeholder="0" />
- +
-
setForm({ ...form, notes: e.target.value })} placeholder="Optional" />
+
setForm({ ...form, notes: e.target.value })} placeholder={t('common.optional', 'Optional')} />
- +
diff --git a/src/app/components/NewRideDialog.tsx b/src/app/components/NewRideDialog.tsx index e155c92..f0e1e5a 100644 --- a/src/app/components/NewRideDialog.tsx +++ b/src/app/components/NewRideDialog.tsx @@ -8,6 +8,7 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { RoadRoute } from '../lib/route'; import RideLocationPicker, { type PlaceValue } from './RideLocationPicker'; +import { useAppContext } from '../contexts/AppContext'; interface Props { open: boolean; @@ -17,6 +18,7 @@ interface Props { /** Shared "Create New Ride" dialog with map-based pickup/destination selection. */ export default function NewRideDialog({ open, onOpenChange, onCreated }: Props) { + const { t } = useAppContext(); const [customerName, setCustomerName] = useState(''); const [customerPhone, setCustomerPhone] = useState(''); const [pickup, setPickup] = useState(null); @@ -79,11 +81,11 @@ export default function NewRideDialog({ open, onOpenChange, onCreated }: Props) }} > - Create New Ride + {t('rides.createRide', 'Create New Ride')}
-
setCustomerName(e.target.value)} placeholder="Leave blank for Guest" />
-
setCustomerPhone(e.target.value)} placeholder="0911 234567" />
+
setCustomerName(e.target.value)} placeholder={t('dashboard.leaveBlankForGuest', 'Leave blank for Guest')} />
+
setCustomerPhone(e.target.value)} placeholder="0911 234567" />
- +
diff --git a/src/app/components/RideLocationPicker.tsx b/src/app/components/RideLocationPicker.tsx index f5adcd9..6e44aec 100644 --- a/src/app/components/RideLocationPicker.tsx +++ b/src/app/components/RideLocationPicker.tsx @@ -5,6 +5,7 @@ import { api } from '../lib/api'; import { estimateFareEtb, type RoadRoute } from '../lib/route'; import { formatETB } from '../lib/format'; import GebetaMapView, { type MapPoint } from './GebetaMapView'; +import { useAppContext } from '../contexts/AppContext'; export interface PlaceValue { lat: number; @@ -32,6 +33,7 @@ interface Props { * to drop a pin. Shows road-accurate distance once both points are set. */ export default function RideLocationPicker({ pickup, dropoff, onChange, onRouteChange }: Props) { + const { t } = useAppContext(); const [active, setActive] = useState<'pickup' | 'dropoff'>('pickup'); const [query, setQuery] = useState(''); const [suggestions, setSuggestions] = useState([]); @@ -114,7 +116,7 @@ export default function RideLocationPicker({ pickup, dropoff, onChange, onRouteC className={`flex items-center gap-2 p-2 rounded-lg border-2 text-sm transition-colors ${active === 'pickup' ? 'border-[#10B981] bg-[#10B981]/5' : 'border-border hover:border-muted-foreground'}`} > - {pickup ? pickup.address : 'Set Pickup'} + {pickup ? pickup.address : t('dashboard.setPickup', 'Set Pickup')} @@ -138,7 +140,7 @@ export default function RideLocationPicker({ pickup, dropoff, onChange, onRouteC pick(suggestions[0]); } }} - placeholder={`Search ${active === 'pickup' ? 'pickup' : 'destination'}…`} + placeholder={active === 'pickup' ? t('dashboard.searchPickup', 'Search pickup...') : t('dashboard.searchDestination', 'Search destination...')} className="pl-10 pr-9" /> {searching && } @@ -169,7 +171,7 @@ export default function RideLocationPicker({ pickup, dropoff, onChange, onRouteC

- Setting {active === 'pickup' ? 'pickup' : 'destination'} — pick a suggestion above, or click the map. + {active === 'pickup' ? t('dashboard.settingPickup', 'Setting pickup') : t('dashboard.settingDestination', 'Setting destination')} — pick a suggestion above, or click the map.

& - VariantProps & { - asChild?: boolean; - }) { +const Button = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + } +>(({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; - return ( ); -} +}); +Button.displayName = "Button"; export { Button, buttonVariants }; diff --git a/src/app/contexts/AppContext.tsx b/src/app/contexts/AppContext.tsx index 8dba39b..a2674ff 100644 --- a/src/app/contexts/AppContext.tsx +++ b/src/app/contexts/AppContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; export type Language = 'en' | 'am' | 'om'; export type ThemeMode = 'light' | 'dark'; @@ -34,6 +34,8 @@ const translations = { profile: 'Profile', account: 'My Account', viewDetails: 'View Details', + adminBadge: 'Admin', + callCenter: 'Call Center', saveChanges: 'Save Changes', cancel: 'Cancel', addNew: 'Add New', @@ -42,6 +44,47 @@ const translations = { theme: 'Theme', darkMode: 'Dark Mode', lightMode: 'Light Mode', + optional: 'Optional', + }, + dashboard: { + newRide: 'New Ride', + pendingRides: 'Pending Rides', + activeRides: 'Active Rides', + driverStatus: 'Driver Status', + available: 'Available', + busy: 'Busy', + offline: 'Offline', + live: 'Live', + track: 'Track', + assign: 'Assign', + reNotifyDrivers: 'Re-notify Drivers', + noPendingRides: 'No pending rides', + noActiveRides: 'No active rides', + noAvailableDrivers: 'No available drivers', + viewDetails: 'View Details', + assignDriverTo: 'Assign Driver to {0}', + logCall: 'Log Call', + logCallTitle: 'Log a Call', + customerPhone: 'Customer Phone *', + customerName: 'Customer Name', + durationMinutes: 'Duration (minutes)', + outcome: 'Outcome', + completed: 'Completed', + missed: 'Missed', + abandoned: 'Abandoned', + notes: 'Notes', + saveCall: 'Save Call', + leaveBlankForGuest: 'Leave blank for Guest', + setPickup: 'Set Pickup', + setDestination: 'Set Destination', + searchPickup: 'Search pickup...', + searchDestination: 'Search destination...', + settingPickup: 'Setting pickup', + settingDestination: 'Setting destination', + unassigned: 'Unassigned', + guest: 'Guest', + callTest: 'Call Test', + testCustomer: 'Test Customer', }, drivers: { title: 'Driver Management', @@ -49,6 +92,14 @@ const translations = { searchPlaceholder: 'Search by Name, Phone, Vehicle, License Plate...', addButton: 'Add New Driver', addTitle: 'Add New Driver', + fullName: 'Full Name *', + phone: 'Phone Number', + email: 'Email *', + vehicleType: 'Vehicle Type', + vehicleModel: 'Vehicle Model', + licensePlate: 'License Plate', + licenseNumber: 'License Number *', + initialCouponBalance: 'Initial Coupon Balance', save: 'Add Driver', edit: 'Edit Driver', update: 'Update Driver', @@ -57,15 +108,59 @@ const translations = { createSuccess: 'Driver added successfully!', fillFields: 'Please fill in all required fields', driverName: 'Driver Name', - phone: 'Phone Number', vehicle: 'Vehicle Model', - licensePlate: 'License Plate', couponBalance: 'Initial Coupon Balance', totalDrivers: 'Total Drivers', available: 'Available', busy: 'Busy', noDrivers: 'No drivers found', }, + callLogs: { + title: 'Call Logs', + subtitle: 'Detailed history of all customer calls', + logCall: 'Log Call', + totalCalls: 'Total Calls', + completed: 'Completed', + missed: 'Missed', + abandoned: 'Abandoned', + avgDuration: 'Avg Duration', + searchPlaceholder: 'Search by customer, phone, or operator…', + allStatus: 'All Status', + history: 'Call History', + time: 'Time', + customer: 'Customer', + phone: 'Phone', + operator: 'Operator', + status: 'Status', + duration: 'Duration', + rideId: 'Ride ID', + notes: 'Notes', + noLogs: 'No call logs found', + }, + analytics: { + title: 'Analytics & Reports', + subtitle: 'Overview of dispatch performance', + today: 'Today', + week: 'This Week', + month: 'This Month', + year: 'This Year', + totalRides: 'Total Rides', + totalRevenue: 'Total Revenue', + activeDrivers: 'Active Drivers', + totalCalls: 'Total Calls', + completedRides: 'Completed Rides', + completedPercent: '{0}% of total', + cancelledRides: 'Cancelled Rides', + activeRides: 'Active Rides', + avgFare: 'Avg fare {0}', + operatorPerformance: 'Operator Performance', + operator: 'Operator', + shift: 'Shift', + ridesCreated: 'Rides Created', + active: 'active', + morning: 'Morning', + noOperators: 'No operators yet', + }, operators: { title: 'Operators Management', subtitle: 'Manage call center operators and track performance', @@ -79,6 +174,15 @@ const translations = { subtitle: 'View and manage all ride records', searchPlaceholder: 'Search by Ride ID, Customer, Location...', filterStatus: 'Filter by Status', + allStatus: 'All Status', + pendingStatus: 'Pending', + assignedStatus: 'Assigned', + acceptedStatus: 'Accepted', + driverArrivingStatus: 'Driver Arriving', + inProgressStatus: 'In Progress', + completedStatus: 'Completed', + cancelledStatus: 'Cancelled', + expiredStatus: 'Expired', addRide: 'New Ride', createRide: 'Create New Ride', rideCreated: 'Ride created successfully!', @@ -90,6 +194,7 @@ const translations = { rideNotFound: 'Ride not found', backToDashboard: 'Back to Dashboard', rideProgress: 'Ride Progress', + rideTitle: 'Ride #{0}', driverInformation: 'Driver Information', rideID: 'Ride ID', createdAt: 'Created At', @@ -101,6 +206,20 @@ const translations = { carDetails: 'Vehicle Details', vehicleInfo: 'Vehicle Information', licensePlate: 'License Plate', + customerInformation: 'Customer Information', + name: 'Name', + phoneNumber: 'Phone Number', + pickup: 'Pickup', + destination: 'Destination', + driverName: 'Driver Name', + vehicle: 'Vehicle', + callDriver: 'Call Driver', + liveTracking: 'Live Tracking', + rideIdLabel: 'Ride ID', + updated: 'Updated', + roadDistance: 'Road distance', + finalFare: 'Final Fare', + fare: 'Fare', }, coupons: { title: 'Coupon Management', @@ -139,124 +258,481 @@ const translations = { operators: 'ኦፕሬተሮች', callLogs: 'የጥሪ መዝገቦች', rides: 'ጉዞዎች', - drivers: 'አነሶች', - coupons: 'ኩፖኖች', - settings: 'ቅንብሮች', + drivers: 'አሽከርካሪዎች', + om: { + nav: { + dashboard: 'Boordii', + analytics: 'Xiinxala', + operators: 'Opeeretaroota', + callLogs: 'Galmee Bilbilaa', + rides: 'Imala', + drivers: 'Draayivaroota', + coupons: 'Kuuponoota', + settings: "Qindaa'inaa", }, auth: { - welcome: 'ወደ TEKUMMA እንኳን በደህና መጡ', - description: 'ለዲስፓች ስርዓት መዳረሻ ይግቡ', - login: 'ግባ', - loggingIn: 'በመግባት ላይ...', - operator: 'ኦፕሬተር', - admin: 'አስተዳደር', - email: 'ኢሜል', - password: 'የሚከተለው ቃል', - selectRole: 'እንደ ማን ትግቡ', - fillAll: 'እባክዎ ሁሉንም ይሙሉ', - loginSuccessful: 'በተሳካነት ወደ ደረጃው ገባ', + welcome: 'TEKUMMA keessa baga nagaan dhuftan', + description: 'Sirna geejjibaa fayyadamuu dhaaf seenaa', + login: 'Seeni', + loggingIn: 'Seenaa jira...', + operator: 'Opeeretara', + admin: 'Bulchaa', + email: 'Emeelii', + password: 'Jecha Darbiinsaa', + selectRole: 'Akka eenyutti seentuu filadhu', + fillAll: 'Dirreelee hundumaa guuti', + loginSuccessful: 'Milkaawinaan seentee jirta', }, common: { - logout: 'ውጣ', - notifications: 'ማስጠንቀቂያዎች', - profile: 'መገለጫ', - account: 'የእኔ መለያ', - viewDetails: 'ዝርዝር ተመልከት', - saveChanges: 'ለውጦችን አስቀመጥ', - cancel: 'አቁም', - addNew: 'አዲስ ያክሉ', - search: 'ፈልግ', - language: 'ቋንቋ', - theme: 'ቅጥ', - darkMode: 'ጨለማ ሁኔታ', - lightMode: 'ብርሃን ሁኔታ', + logout: 'Bahi', + notifications: 'Beeksisoota', + profile: 'Porofaayilii', + account: 'Akaawuntii Koo', + viewDetails: "Bal'inaan Ilaali", + saveChanges: "Jijjiirraa Olkaa'i", + cancel: 'Haqi', + addNew: 'Haaraa Dabali', + search: 'Barbaadi', + language: 'Afaan', + theme: 'Bifuma', + darkMode: 'Haala Dukkanaa', + lightMode: 'Haala Iftaa', + name: 'Maqaa', + phone: 'Lakkoofsa Bilbilaa', + email: 'Emeelii', + status: 'Haala', + actions: 'Tarkaanfiiwwan', + noResults: 'Waan argame hin jiru', + loading: "Fe'amaa jira...", + back: "Deebi'i", + save: "Olkaa'i", + edit: 'Gulaali', + delete: 'Haqi', + confirm: 'Mirkaneessi', + role: 'Gahee', + callCenter: 'Wiirtuu Bilbilaa', + }, + dashboard: { + newRide: 'Imala Haaraa', + logCall: 'Bilbila Galmeessi', + pendingRides: 'Imala Eeggamaa Jiraan', + activeRides: 'Imala Socho-aa', + noPendingRides: 'Imala eeggamaa jiru hin jiru', + noActiveRides: 'Imala socho-aa hin jiru', + driverStatus: 'Haala Draayivarootaa', + available: 'Qophii', + busy: 'Bishi', + offline: 'Cufaa', + assignDriver: 'Draayivara Ramadi', + assignTo: 'Draayivara Ramadi — ', + noAvailableDrivers: 'Draayivara qophii jiru hin jiru', + renotifyDrivers: 'Draayivarootaaf Ammas Ergi', + track: 'Hordofi', + assign: 'Ramadi', + viewDetails: "Bal'inaan Ilaali", + coupon: 'Kuuponii', }, drivers: { - title: 'የአነሶች አስተዳደር', - subtitle: 'የአነሶች ጉዞ አስተዳደር ያስተካክሉ', - searchPlaceholder: 'በስም, ስልክ, ተሽከርካሪ, መዝገበ መኪና ፈልግ...', - addButton: 'አዲስ አነስ ያክሉ', - addTitle: 'አዲስ አነስ ያክሉ', - save: 'አነሱን ያክሉ', - edit: 'አንሱን አስተካክሉ', - update: 'አስተካክል', - viewProfile: 'መገለጫ ይመልከቱ', - editSuccess: 'አነሱ በተሳካ ሁኔታ ተሻሽሏል!', - createSuccess: 'አነሱ ተጨምሯል!', - fillFields: 'እባክዎ ያስፈልጉትን ሁሉ ይሙሉ', - driverName: 'የአነሱ ስም', - phone: 'የስልክ ቁጥር', - vehicle: 'የተሽከርካሪ ሞዴል', - licensePlate: 'የፈቃድ ታስሪ', - couponBalance: 'የኩፖን ዕረፍት', - totalDrivers: 'አጠቃላይ አነሶች', - available: 'የሚገኝ', - busy: 'ሙሉ ስራ', - noDrivers: 'አነሶች አልተገኙም', + title: 'Bulchiinsa Draayivaroota', + subtitle: 'Tarree draayivaroota keessanii bulchaa', + searchPlaceholder: 'Maqaa, bilbila, konkolaataa, gabatee konkolaataan barbaadi...', + addButton: 'Draayivara Haaraa Dabali', + addTitle: 'Draayivara Haaraa Dabali', + save: 'Draayivara Dabali', + edit: 'Draayivara Gulaali', + update: 'Haaromsi', + viewProfile: 'Porofaayilii Ilaali', + editSuccess: 'Draayivarri milkaawinaan haaromfame!', + createSuccess: 'Draayivarri milkaawinaan dabalame!', + fillFields: 'Maqaa, bilbila, emeelii, gabatee fi lakkoofsa heyyamaa barbaachisaadha', + driverName: 'Maqaa Draayivaraa', + fullName: 'Maqaa Guutuu', + phone: 'Bilbila', + vehicle: 'Moodela Konkolaataa', + vehicleType: 'Gosa Konkolaataa', + licensePlate: 'Gabatee Lakkoofsa Konkolaataa', + licenseNumber: 'Lakkoofsa Heyyama Konkolaachisummaa', + couponBalance: 'Madaallii Kuuponii Jalqabaa', + totalDrivers: 'Waliigala Draayivaroota', + available: 'Qophii', + busy: 'Bishi', + noDrivers: 'Draayivarri tokkollee hin argamne', + commission: 'Komishinii Idilee (%)', + }, + operators: { + title: 'Bulchiinsa Opeeretaroota', + subtitle: "Opeeretaroota Wiirtuu Bilbilaa Bulchi fi Ga'umsa Isaanii Hordofi", + searchPlaceholder: 'Maqaa, emeelii, ykn bilbilaan barbaadi...', + addButton: 'Opeeretara Dabalata Uumi', + addTitle: 'Opeeretara Haaraa Dabali', + addDescription: 'Opeeretara wiirtuu bilbilaa haaraa sirna keessatti dabali', + viewDetails: "Bal'inaan Ilaali", + noOperators: 'Opeeretarri tokkollee hin argamne', + totalOperators: 'Waliigala Opeeretaroota', + onlineNow: 'Amma Intarneetii Irra Jiru', + totalRidesCreated: 'Waliigala Imala Uumame', + totalCalls: 'Waliigala Bilbilaa', + ridesCreated: 'Imala Uumame', + fullName: 'Maqaa Guutuu', + password: 'Jecha Darbiinsaa', + shift: 'Dabare', + morning: 'Ganama', + afternoon: 'Waaree Booda', + night: 'Halkan', + active: 'Socho-aa', + inactive: 'Hin Socho-in', + }, + rides: { + title: 'Seenaa Imalaa', + subtitle: 'Galmee imala hundumaa ilaali fi bulchi', + searchPlaceholder: 'Eenyummeessaa Imalaa (Ride ID), Maamila, Draayivaraan barbaadi...', + filterStatus: 'Haala Filadhu', + allStatus: 'Haala Hundumaa', + pending: 'Eeggamaa', + assigned: 'Ramadame', + accepted: 'Fudhataame', + driverArriving: 'Draayivarri dhufaa jira', + inProgress: 'Deemaa Jira', + completed: 'Xumurame', + cancelled: 'Haqame', + expired: 'Yeroon Darbee', + addRide: 'Imala Haaraa', + createRide: 'Imala Haaraa Uumi', + rideCreated: 'Imalli uumame — draayivaroota dhiyoo jiraniif ergame', + customerName: 'Maqaa Maamila', + customerPhone: 'Bilbila Maamila', + pickupLocation: "Ka'umsa", + dropoffLocation: "Ga'umsa", + rideDetails: "Bal'ina Imalaa", + rideNotFound: 'Imalli hin argamne', + backToRides: "Gara Imalatti Deebi'i", + rideProgress: 'Guddina Imalaa', + driverInformation: 'Odeeffannoo Draayivaraa', + customerInformation: 'Odeeffannoo Maamila', + rideID: 'ID Imalaa', + createdAt: 'Yeroo Uumame', + lastUpdated: 'Yeroo Haaromfame', + fare: 'Gatii Imalaa', + finalFare: 'Gatii Xumuraa', + distance: 'Fageenya', + cancelRide: 'Imala Haqi', + noDriverAssigned: 'Draayivarri hin ramadamne', + notAssigned: 'Ramaddii Malee', + licensePlate: 'Gabatee Lakkoofsa', + liveTracking: 'Hordoffii Kallattii', + showing: 'Agarsiisaa jira', + of: 'keessaa', + previous: 'Kan Duraa', + next: 'Kan Antaa', + rideId: 'ID Imalaa', + customer: 'Maamila', + pickup: "Ka'umsa", + dropoff: "Ga'umsa", + driver: 'Draayivara', + time: 'Yeroo', + actions: 'Tarkaanfiiwwan', + noRides: 'Imalli tokkollee hin argamne', + destination: "Ga'umsa", + phoneNumber: 'Lakkoofsa Bilbilaa', + driverName: 'Maqaa Draayivaraa', + vehicle: 'Konkolaataa', + callDriver: 'Draayivara Bilbili', + trackRide: 'Imala kallattiin hordofi', + }, + coupons: { + title: 'Bulchiinsa Kuuponii', + subtitle: 'Madaallii kuuponii draayivarootaa bulchi', + searchPlaceholder: 'Maqaa, lakkoofsa gabatee, ykn bilbilaan barbaadi...', + refill: 'Kuuponii Guuti', + confirmRefill: 'Guutuu Mirkaneessi', + currentBalance: 'Madaallii Kuuponii', + lowBalance: 'Madaallii Gad-aanaa', + low: 'Gad-aanaa', + moderate: 'Giddugaleessa', + moderateBalance: 'Madaallii Giddugaleessa', + good: 'Gaarii', + goodBalance: 'Madaallii Gaarii', + noDrivers: 'Draayivarri tokkollee hin argamne', + totalDrivers: 'Waliigala Draayivaroota', + totalCoupons: 'Waliigala Kuuponoota', + lowBalanceCount: 'Madaallii Gad-aanaa', + enterAmount: 'Lakkoofsa kuuponii galchi', + amountToAdd: "Baay'ina Dabalamuu", + newBalance: 'Madaallii Haaraa', + currentBalanceLabel: 'Madaallii Ammaa', + plateLabel: 'Gabatee Lakkoofsa', + coupons: 'kuuponii', + }, + callLogs: { + title: 'Galmee Bilbilaa', + subtitle: 'Seenaa tarreeffama bilbila maamiltootaa', + searchPlaceholder: 'Maamila, bilbila, ykn opeeretaraan barbaadi...', + logCall: 'Bilbila Galmeessi', + logTitle: 'Bilbila Galmeessi', + totalCalls: 'Waliigala Bilbilaa', + completed: 'Xumurame', + missed: 'Dagatame', + avgDuration: 'Turtii Giddugaleessa', + callHistory: 'Seenaa Bilbilaa', + time: 'Yeroo', + customer: 'Maamila', + phone: 'Bilbila', + operator: 'Opeeretara', + status: 'Haala', + duration: 'Turtii', + rideId: 'ID Imalaa', + notes: 'Yaadannoo', + noLogs: 'Galmeen bilbilaa tokkollee hin argamne', + allStatus: 'Haala Hundumaa', + abandoned: 'Dhiifame', + customerPhone: 'Bilbila Maamila', + customerName: 'Maqaa Maamila', + durationMinutes: 'Turtii (Daqiiqaa)', + outcome: "Bu'aa", + saveCall: "Bilbila Olkaa'i", + }, + analytics: { + title: 'Xiinxala fi Gabaasota', + subtitle: 'Waliigala raawwii geejjiba', + today: "Har'aa", + thisWeek: 'Torbee Kana', + thisMonth: "Ji'a Kana", + thisYear: 'Waggaa Kana', + totalRides: 'Waliigala Imalaa', + totalRevenue: 'Galii Waliigalaa', + activeDrivers: 'Draayivaroota Socho-aa', + completedRides: 'Imala Xumurame', + cancelledRides: 'Imala Haqame', + activeRides: 'Imala Socho-aa', + avgFare: 'Gatii Giddugaleessa', + ofTotal: '% waliigalaa', + operatorPerformance: "Ga'umsa Opeeretaraa", + noOperators: 'Opeeretarri ammaaf hin jiru', + shift: 'Dabare', + ridesCreated: 'Imala Uumame', + totalCalls: 'Waliigala Bilbilaa', + }, + settings: { + title: "Qindaa'inaa", + subtitle: "Akaawuntii keessaniif qindaa'ina sirna Tokuma bulchaa", + profile: 'Porofaayilii', + profileDescription: 'Odeeffannoo akaawuntii keessanii', + pricingTitle: 'Gatii, Ergaa & Komishinii', + pricingDescription: 'Seera Hojii Tokuma Kallattiin', + pricingReadOnly: '(dubbisuu qofa — jijjiiruu kan dandayu bulchaa qofa)', + saveSystem: "Qindaa'ina Sirna Olkaa'i", + systemSaved: "Qindaa'inni sirna olkaayame", + adminOnly: 'Bulchaan qofa jijjiiruu dandaya', + name: 'Maqaa', + role: 'Gahee', + admin: 'Bulchaa', + callCenter: 'Wiirtuu Bilbilaa', + }, + employeeProfile: { + driver: 'Draayivara', + profile: 'Porofaayilii', + details: "Bal'ina", + email: 'Emeelii', + status: 'Haala', + couponBalance: 'Madaallii Kuuponii', + coupons: 'kuuponii', + vehicleType: 'Gosa Konkolaataa', + vehicleModel: 'Moodela Konkolaataa', + licensePlate: 'Gabatee Lakkoofsa', + licenseNumber: 'Lakkoofsa Heyyama', + rating: 'Madaallii', + completedRides: 'Imala Xumurame', + lastLocation: 'Bakka Dhumaa', + commission: 'Komishinii Tokuma', + manageCoupons: 'Kuuponii Bulchi', + notFound: 'Draayivarri hin argamne', + backToDrivers: "Gara Draayivarootti Deebi'i", + }, + }, + rideId: 'የጉዞ መለያ', + notes: 'ማስታወሻዎች', + noLogs: 'ምንም የጥሪ መዝገብ አልተገኘም', + }, + analytics: { + title: 'ተንተና እና ሪፖርቶች', + subtitle: 'አጠቃላይ የጥሪ እና የጉዞ ስርጭት አፈፃፀም', + today: 'ዛሬ', + week: 'ይህ ሳምንት', + month: 'ይህ ወር', + year: 'ይህ ዓመት', + totalRides: 'ጠቅላላ ጉዞዎች', + totalRevenue: 'ጠቅላላ ገቢ', + activeDrivers: 'ንቁ አሽከርካሪዎች', + totalCalls: 'ጠቅላላ ጥሪዎች', + completedRides: 'የተጠናቀቁ ጉዞዎች', + completedPercent: 'ከጠቅላላው {0}%', + cancelledRides: 'የተሰረዙ ጉዞዎች', + activeRides: 'ንቁ ጉዞዎች', + avgFare: 'አማካይ ክፍያ {0}', + operatorPerformance: 'የኦፐሬተሮች አፈፃፀም', + operator: 'ኦፐሬተር', + status: 'ሁኔታ', + shift: 'የስራ ፈርም', + ridesCreated: 'የተፈጠሩ ጉዞዎች', + active: 'ንቁ / ስራ ላይ', + morning: 'ጧት', + noOperators: 'እስካሁን ምንም ኦፐሬተር የለም', }, operators: { - title: 'የኦፕሬተሮች አስተዳደር', - subtitle: 'የመስመር ላይ ኦፕሬተሮችን እና አፈፃፀም ይቆጣጠሩ', - searchPlaceholder: 'በስም, ኢሜል, ወይም ስልክ ፈልግ...', - addButton: 'ኦፕሬተር ያክሉ', - viewDetails: 'ዝርዝር ይመልከቱ', - noOperators: 'ኦፕሬተሮች አልተገኙም', + title: 'የኦፐሬተሮች አስተዳደር', + subtitle: 'የጥሪ ማዕከል ኦፐሬተሮችን ያስተዳድሩ እና አፈፃፀምን ይከታተሉ', + searchPlaceholder: 'በስም፣ በኢሜይል ወይም በስልክ ፈልግ...', + addButton: 'ኦፐሬተር ጨምር', + viewDetails: 'ዝርዝር እይ', + noOperators: 'ምንም የተገኘ ኦፐሬተር የለም', + totalOperators: 'ጠቅላላ ኦፐሬተሮች', + onlineNow: 'አሁን መስመር ላይ ያሉ', + totalRidesCreated: 'የተፈጠሩ ጠቅላላ ጉዞዎች', + totalCalls: 'ጠቅላላ ጥሪዎች', + addNewTitle: 'አዲስ ኦፐሬተር ጨምር', + addNewDescription: 'አዲስ የጥሪ ማዕከል ኦፐሬተር ወደ ስርዓቱ ያስገቡ', + fullName: 'ሙሉ ስም', + email: 'ኢሜይል', + phone: 'የስልክ ቁጥር', + password: 'የይለፍ ቃል', + shift: 'የስራ ፈቃድ / ፈShift', + morning: 'ጧት', + cancel: 'ሰርዝ / ተመለስ', + addOperator: 'ኦፐሬተር ጨምር', + active: 'ንቁ', + inactive: 'አልተነቃቁም', }, rides: { - title: 'የጉዞ ታሪክ', - subtitle: 'ሁሉንም የጉዞ መዝገቦች ይመልከቱና ያስተዳድሩ', - searchPlaceholder: 'በጉዞ መታወቂያ, ደንበኛ, ወይም ቦታ ፈልግ...', - filterStatus: 'በሁኔታ ይፈልጉ', - addRide: 'ጉዞ አዲስ አቀርብ', - createRide: 'አዲስ ጉዞ ያብሩ', - rideCreated: 'ጉዞ በተሳካነት ተፈጥሯል!', + title: 'ጉዞዎች', + subtitle: 'ሁሉንም የጉዞ መዝገቦች ይመልከቱ እና ያስተዳድሩ', + searchPlaceholder: 'በጉዞ መለያ (Ride ID)፣ በደንበኛ፣ በአሽከርካሪ ፈልግ...', + filterStatus: 'በሁኔታ ይጣሩ', + allStatus: 'ሁሉም ሁኔታዎች', + pendingStatus: 'በሂደት ላይ ያለ', + assignedStatus: 'የተመደበ', + acceptedStatus: 'የተቀበለው', + driverArrivingStatus: 'አሽከርካሪው እየደረሰ ነው', + inProgressStatus: 'በጉዞ ላይ ያለ', + completedStatus: 'የተጠናቀቀ', + cancelledStatus: 'የተሰረዘ', + expiredStatus: 'ጊዜው ያለፈበት', + addRide: 'አዲስ ጉዞ', + createRide: 'አዲስ ጉዞ ፍጠር', + rideCreated: 'ጉዞ በተሳካ ሁኔታ ተፈጥሯል!', customerName: 'የደንበኛ ስም', customerPhone: 'የደንበኛ ስልክ', - pickupLocation: 'የመውሰድ ቦታ', - dropoffLocation: 'የማሰናከል ቦታ', + pickupLocation: 'መነሻ ቦታ', + dropoffLocation: 'መድረሻ ቦታ', rideDetails: 'የጉዞ ዝርዝር', rideNotFound: 'ጉዞ አልተገኘም', backToDashboard: 'ወደ ዳሽቦርድ ተመለስ', rideProgress: 'የጉዞ ሂደት', - driverInformation: 'የአንሱ መረጃ', - rideID: 'የጉዞ መታወቂያ', + rideTitle: 'ጉዞ #{0}', + driverInformation: 'የአሽከርካሪ መረጃ', + rideID: 'የጉዞ መለያ', createdAt: 'የተፈጠረበት', - lastUpdated: 'የታዘዘው', - estimatedFare: 'ተጠናቀቀ ወጪ', - cancelRide: 'ጉዞን ሰርዝ', - noDriverAssigned: 'አንስ አልተመደበም', - liveTrackingPlaceholder: 'የቀጥታ ካርታ ከGoogle Maps ጋር ሲገናኝ እዚህ ይታያል', - carDetails: 'የመኪና ዝርዝር', - vehicleInfo: 'የመኪና መረጃ', + lastUpdated: 'መጨረሻ የተሻሻለበት', + estimatedFare: 'ተገምቶ የሚከፈል ክፍያ', + cancelRide: 'ጉዞ ሰርዝ', + noDriverAssigned: 'አሽከርካሪ አልተመደበም', + liveTrackingPlaceholder: 'የቀጥታ ካርታ ከGoogle Maps API ጋር ሲዋቀር እዚህ ይታያል', + carDetails: 'የተሽከርካሪ ዝርዝር', + vehicleInfo: 'የተሽከርካሪ መረጃ', + customerInformation: 'የደንበኛ መረጃ', + name: 'ስም', + phoneNumber: 'የስልክ ቁጥር', + pickup: 'መነሻ ቦታ', + destination: 'መድረሻ', + driverName: 'የአሽከርካሪ ስም', + vehicle: 'ተሽከርካሪ', + callDriver: 'አሽከርካሪ ይደውሉ', + liveTracking: 'የቀጥታ ክትትል', + rideIdLabel: 'የጉዞ መለያ', + updated: 'የተሻሻለ', + roadDistance: 'የመንገድ ርቀት', + finalFare: 'የመጨረሻ ክፍያ', + fare: 'ክፍያ', + rideId: 'የጉዞ መለያ', + customer: 'ደንበኛ', + dropoff: 'መድረሻ ቦታ', + driver: 'አሽከርካሪ', + status: 'ሁኔታ', + time: 'ሰዓት', + actions: 'እርምጃዎች', + noRides: 'ምንም የተገኘ ጉዞ የለም', + showingCount: 'ከ {1} ጉዞዎች ውስጥ {0} እያሳየ ነው', + previous: 'የቀደመው', + next: 'ቀጣይ', + track: 'ተከታተል', + reNotifyNearby: 'አስተገባቸውን ለአሽከርካሪዎች ድጋሚ ላክ', + notAssigned: 'ያልተመደበ', + newRide: 'አዲስ ጉዞ', }, coupons: { title: 'የኩፖን አስተዳደር', - subtitle: 'የአነሶች የኩፖን ሂሳብ እና ግብይቶችን ይቆጣጠሩ', - selectDriver: 'አነስ ይምረጡ፡', - refill: 'ኩፖን ያስገቡ', - confirmRefill: 'አረጋግጥ', - currentBalance: 'የአሁኑ የኩፖን ሂሳብ', - lowBalance: 'የታነ ሂሳብ ማስጠንቀቂያ', - moderateBalance: 'መካከለኛ ሂሳብ', - goodBalance: 'ጥሩ ሂሳብ', + subtitle: 'የአሽከርካሪዎችን የኩፖን ቀሪ ሂሳብ ያስተዳድሩ', + selectDriver: 'አሽከርካሪ ይምረጡ:', + refill: 'ኩፖን ሙላ', + confirmRefill: 'መሙላቱን አረጋግጥ', + currentBalance: 'የአሁኑ ቀሪ ሂሳብ', + lowBalance: 'ዝቅተኛ ቀሪ ሂሳብ ማስጠንቀቂያ', + moderateBalance: 'መካከለኛ ቀሪ ሂሳብ', + goodBalance: 'ጥሩ ቀሪ ሂሳብ', transactionHistory: 'የግብይት ታሪክ', - noTransactions: 'ለይም አንኳን ግብይቶች አልተገኙም', + noTransactions: 'ለዚህ አሽከርካሪ ምንም ግብይት አልተገኘም', enterAmount: 'የኩፖን ብዛት ያስገቡ', - validAmount: 'እባክዎ እውነተኛ መጠን ያስገቡ', + validAmount: 'እባክዎ ትክክለኛ መጠን ያስገቡ', + totalDrivers: 'ጠቅላላ አሽከርካሪዎች', + totalCoupons: 'ጠቅላላ ኩፖኖች', + lowBalanceCount: 'ዝቅተኛ ቀሪ ሂሳብ', + searchPlaceholder: 'በስም፣ በሰሌዳ ቁጥር ወይም በስልክ ፈልግ...', + refillTitle: 'ኩፖን ሙላ — {0}', + phone: 'የስልክ ቁጥር', + plate: 'የሰሌዳ ቁጥር', + status: 'ሁኔታ', + amountToAdd: 'የሚጨመረው መጠን', + placeholder: 'የኩፖን ብዛት ያስገቡ', + newBalance: 'አዲስ ቀሪ ሂሳብ', + good: 'ጥሩ', + moderate: 'መካከለኛ', + low: 'ዝቅተኛ', + couponBalance: 'የኩፖን ቀሪ ሂሳብ', + noDrivers: 'ምንም አሽከርካሪ አልተገኘም', }, settings: { - title: 'ቅንብሮች', - subtitle: 'የመለያዎን እና የመተግበሪያ ቅንብሮችን ያስተካክሉ', - profileSettings: 'የመገለጫ ቅንብሮች', + title: 'ማስተካከያዎች', + subtitle: 'የመለያዎን እና የቶኩማ ስርዓት ውቅሮችን ያስተዳድሩ', + profileSettings: 'ፕሮፋይል ቅንብሮች', notificationSettings: 'የማስጠንቀቂያ ቅንብሮች', systemSettings: 'የስርዓት ቅንብሮች', - security: 'የደህንነት', - autoAssign: 'እንደ ቅርብ ያሉትን አነሶች በአንድ ጊዜ ይመደቁ', + security: 'ደህንነት', + autoAssign: 'በአቅራቢያ ያለውን አሽከርካሪ በራስ-መደብ', soundAlerts: 'የድምጽ ማስጠንቀቂያዎች', mapKey: 'የGoogle Maps API ቁልፍ', - saveSystem: 'የስርዓት ቅንብሮችን ማስቀመጥ', - changePassword: 'የይለፍ ቃል ይቀይሩ', + saveSystem: 'የስርዓት ቅንብሮችን አስቀምጥ', + changePassword: 'የይለፍ ቃል ቀይር', logout: 'ውጣ', + profile: 'ፕሮፋይል', + yourAccountInfo: 'የመለያዎ መረጃ', + name: 'ስም', + role: 'ሚና', + administrator: 'አስተዳዳሪ (Administrator)', + email: 'ኢሜይል', + pricingDispatchCommission: 'ዋጋ፣ ማሰራጨት እና ኮሚሽን', + businessRules: 'የቶኩማ የንግድ ህጎች (Live)', + baseFare: 'የመነሻ ክፍያ (ETB)', + perKm: 'በኪሎሜትር (ETB)', + timeBlockCharge: 'የጊዜ ብሎክ ክፍያ (ETB)', + timeBlockMinutes: 'የጊዜ ብሎክ (በደቂቃ)', + minimumFare: 'አነስተኛ የጉዞ ክፍያ (ETB)', + dispatchRadius: 'የጥሪ ስርጭት ራዲየስ (km)', + maxDriversNotified: 'ከፍተኛው የማሳወቂያ አሽከርካሪዎች ብዛት', + offerTimeout: 'የጥሪ ማብቂያ ጊዜ (በሰከንድ)', + minCouponBalance: 'አነስተኛ የኩፖን ቀሪ ሂሳብ', + defaultCommission: 'መደበኛ ኮሚሽን (%)', + saveSettings: 'ለውጦችን አስቀምጥ', + callCenter: 'የጥሪ ማዕከል', + readOnly: 'ተነባቢ-ብቻ — ለማስተካከል የአስተዳዳር መዳረሻ ያስፈልጋል', }, }, om: { @@ -289,6 +765,8 @@ const translations = { profile: 'Profaayilii', account: 'Akkaawuntii Koo', viewDetails: 'Faayila Ilaali', + adminBadge: 'Bulchaa / Admin', + callCenter: 'Wiirtuu Bilbilaa', saveChanges: 'Itti fufi jijjiirraa', cancel: 'Haquu', addNew: 'Haaraa dabaluu', @@ -297,13 +775,64 @@ const translations = { theme: 'Foonii', darkMode: 'Haala Dukkanaa', lightMode: 'Haala Ifaa', + optional: 'Filannoo', + }, + dashboard: { + title: 'Boordii', + newRide: 'Imala Haaraa', + pendingRides: 'Imala Eeggamaa Jiraan', + activeRides: 'Imala Hojii', + driverStatus: 'Haala Draayivarootaa', + available: 'Qophii / Jira', + busy: 'Bishi / Bishinga', + offline: 'Cufaa / Offlaayinii', + live: 'Jireenya', + track: 'Hordofi', + assign: 'Ramadi', + reNotifyDrivers: 'Draayivarootaaf Ammas Ergi', + noPendingRides: 'Imala eeggamaa hin jiru', + noActiveRides: 'Imala hojiin hin jiru', + noAvailableDrivers: 'Draayivaroota qophii hin jiru', + viewDetails: 'Ilaalcha', + assignDriverTo: 'Draayivaraa {0}tti Ramadi', + logCall: 'Bilbila Galmeessi', + logCallTitle: 'Bilbila Galmeessi', + customerPhone: 'Bilbila Maamilaa *', + customerName: 'Maqaa Maamilaa', + durationMinutes: 'Yeroon (daqiiqaa)', + outcome: 'Buʼaa', + completed: 'Xumure', + missed: 'Hin gaʼamne', + abandoned: 'Addaan kutame', + notes: 'Yaadannoo', + saveCall: 'Bilbila Olkaa’i', + leaveBlankForGuest: 'Keessummaaaf haala duwwaa dhiisi', + setPickup: 'Bakka Kaʼumsaa Murteessi', + setDestination: 'Bakka Gaʼumsaa Murteessi', + searchPickup: 'Bakka kaʼumsaa barbaadi...', + searchDestination: 'Bakka gaʼumsaa barbaadi...', + settingPickup: 'Bakka kaʼumsaa murteessuu', + settingDestination: 'Bakka gaʼumsaa murteessuu', + unassigned: 'Ramaddii Malee', + guest: 'Keessummaa', + callTest: 'Qorannoo Bilbilaa', + testCustomer: 'Maamila Qorannoo', + noEligibleDrivers: 'Draayivaroota dhihoo hin jiraniiru', }, drivers: { title: 'Bulchiinsa Draayivaroota', subtitle: 'Fleetii draayivaroota keetiin gulaali', searchPlaceholder: 'Maqaa, bilbila, konkolaataa, lakk. poolisii barbaadi...', addButton: 'Draayivarri Haaraa', - addTitle: 'Draayivarri Haaraa Itti Dabaluu', + addTitle: 'Draayivara Haaraa Dabali', + fullName: 'Maqaa Guutuu *', + phone: 'Lakkoofsa Bilbilaa', + email: 'Emeelii *', + vehicleType: 'Gosa Konkolaataa', + vehicleModel: 'Moodela Konkolaataa', + licensePlate: 'Gabatee Lakkoofsa Konkolaataa', + licenseNumber: 'Lakkoofsa Heyyama Konkolaachisummaa *', + initialCouponBalance: 'Madaallii Kuuponii Jalqabaa', save: 'Draayivarri Itti Dabaluu', edit: 'Draayivarri Gulaali', update: 'Gulaali', @@ -312,28 +841,97 @@ const translations = { createSuccess: 'Draayivarri milkaaʼinaan dabalame!', fillFields: 'Maayyiiwwan hunda guuti', driverName: 'Maqaa Draayivaraa', - phone: 'Lakkoofsa Bilbilaa', vehicle: 'Moodeela Konkolaataa', - licensePlate: 'Lakk. Hayyamaa', couponBalance: 'Baalaansii Kupoontii', totalDrivers: 'Draayivaroota Guutuu', available: 'Argamuu', busy: 'Hojii Jiru', noDrivers: 'Draayivarri hin argamne', }, + callLogs: { + title: 'Galmee Bilbilaa', + subtitle: 'Seenaa tarreeffama bilbila maamiltootaa', + logCall: 'Bilbila Galmeessi', + totalCalls: 'Waliigala Bilbilaa', + completed: 'Xumurame', + missed: 'Kan Dagatame / Hin Qabamne', + abandoned: 'Addaan kutame', + avgDuration: 'Turii Giddu-galeessaa', + searchPlaceholder: 'Maamila, bilbila, ykn opeeretaraan barbaadi...', + allStatus: 'Haala Hundumaa', + history: 'Seenaa Bilbilaa', + time: 'Yeroo', + customer: 'Maamila', + phone: 'Bilbila', + operator: 'Opeeretara', + status: 'Haala', + duration: 'Turii', + rideId: 'ID Imalaa', + notes: 'Yaada / Yaadannoo', + noLogs: 'Galmee bilbilaa hin argamne', + }, + analytics: { + title: 'Xiinxala fi Gabaasa', + subtitle: 'Caaseffama raawwii ergaa waliigalaa', + today: 'Har\'a', + week: 'Torban Kana', + month: 'Ji\'a Kana', + year: 'Waggaa Kana', + totalRides: 'Waliigala Imala', + totalRevenue: 'Waliigala Galii', + activeDrivers: 'Draayivaroota Hojii Irra Jiran', + totalCalls: 'Waliigala Bilbilaa', + completedRides: 'Imala Xumurame', + completedPercent: '% {0} waliigala irraa', + cancelledRides: 'Imala Haqame', + activeRides: 'Imala Demaa Jiru', + avgFare: 'Gatii giddu-galeessaa {0}', + operatorPerformance: 'Ga’umsa Opeeretaroota', + operator: 'Opeeretara', + shift: 'Dabare', + ridesCreated: 'Imala Uumame', + active: 'hojii irra', + morning: 'Ganama', + noOperators: 'Opeeretarri hin jiru', + }, operators: { title: 'Bulchiinsa Opeeretaroota', - subtitle: 'Opeeretaroota fi hojiirra oolmaa isaanii toʼadhu', + subtitle: 'Opeeretaroota Wiirtuu Bilbilaa Bulchi fi Gaʼumsa Isaanii Hordofi', searchPlaceholder: 'Maqaa, iimeelii, ykn bilbila barbaadi...', addButton: 'Opeeretara Dabaluu', viewDetails: 'Faayila Ilaali', - noOperators: 'Opeeretaroonni hin argamne', + noOperators: 'Opeeretarri tokkollee hin argamne', + totalOperators: 'Waliigala Opeeretaroota', + onlineNow: 'Amma Intarneetii Irra Mukaan', + totalRidesCreated: 'Waliigala Imala Uumame', + totalCalls: 'Waliigala Bilbilaa', + addNewTitle: 'Opeeretara Haaraa Dabali', + addNewDescription: 'Opeeretara wiirtuu bilbilaa haaraa sirna keessatti dabali', + fullName: 'Maqaa Guutuu', + email: 'Emeelii', + phone: 'Lakkoofsa Bilbilaa', + password: 'Jecha Darbiinsaa (Password)', + shift: 'Deebii / Dabare', + morning: 'Ganama', + cancel: 'Dhiisi / Haqi', + addOperator: 'Opeeretara Dabali', + active: 'Hojii irra jira', + inactive: 'Hojii hin jiru', }, rides: { title: 'Seenaa Imalaa', - subtitle: 'Galmeewwan imalaa hunda ilaali fi toʼadhu', - searchPlaceholder: 'ID imalaa, maqa hojii, bakka barbaadi...', + subtitle: 'Galmee imala hundumaa ilaali fi bulchi', + searchPlaceholder: 'Eenyummeessaa Imalaa (Ride ID), Maamila, Draayivaraan barbaadi...', filterStatus: 'Haala filadhu', + allStatus: 'Haala Hundumaa', + pendingStatus: 'Eeggamaa Jira', + assignedStatus: 'Ramadameera', + acceptedStatus: 'Fudhatameera', + driverArrivingStatus: 'Draayivarri Dhiyaachaa Jira', + inProgressStatus: 'Adeemsa Irra Jira', + completedStatus: 'Xumurameera', + cancelledStatus: 'Haqameera', + expiredStatus: 'Yeroon Isa Jalaa Dhumateera', addRide: 'Imala Haaraa', createRide: 'Imala Haaraa Uumi', rideCreated: 'Imalli milkaaʼinaan uume!', @@ -345,6 +943,7 @@ const translations = { rideNotFound: 'Imalli hin argamne', backToDashboard: 'Deebiʼi Gara Boordii', rideProgress: 'Guddina Imalaa', + rideTitle: 'Imala #{0}', driverInformation: 'Odeeffannoo Draayivaraa', rideID: 'ID Imalaa', createdAt: 'Kan Uumame', @@ -355,26 +954,71 @@ const translations = { liveTrackingPlaceholder: 'Kaartee jireenyaa Google Maps waliin yoo qindaaʼe asitti mulʼata', carDetails: 'Faayila Konkolaataa', vehicleInfo: 'Odeeffannoo Konkolaataa', + rideId: 'ID Imalaa', + customer: 'Maamila', + pickup: 'Kaʼumsa', + dropoff: 'Gaʼumsa', + driver: 'Draayivara', + status: 'Haala', + time: 'Yeroo', + actions: 'Tarkaanfiiwwan', + noRides: 'Imalli tokkollee hin argamne', + showingCount: 'Imala {1} keessaa {0} agarsiisaa jira', + previous: 'Kan Duraa', + next: 'Kan Antaa / Kan Eegalu', + track: 'Hordofi', + reNotifyNearby: 'Draayivaroota naannoo ammas ergi', + notAssigned: 'Hin ramadame', + newRide: 'Imala Haaraa', + customerInformation: 'Odeeffannoo Maamilaa', + name: 'Maqaa', + phoneNumber: 'Lakkoofsa Bilbilaa', + destination: 'Bakka Gahumsaa', + driverName: 'Maqaa Draayivaraa', + vehicle: 'Konkolaataa', + callDriver: 'Draayivaraa Bilbili', + liveTracking: 'Hordoffii Jireenyaa', + rideIdLabel: 'ID Imalaa', + updated: 'Fooyyaʼaa', + roadDistance: 'Fageenya Karaa', + finalFare: 'Kaffaltii Dhumaa', + fare: 'Kaffaltii', }, coupons: { - title: 'Bulchiinsa Kupoontaa', - subtitle: 'Balansi kouponii draayivaroota fi galmee isaaniitiin gulaali', + title: 'Bulchiinsa Kuuponii', + subtitle: 'Madaallii kuuponii draayivarootaa bulchi', selectDriver: 'Draayivara Filadhu:', - refill: 'Kupoontii deebisi', - confirmRefill: 'Mirkanneessi', - currentBalance: 'Balansi Kouponii Ammaa', - lowBalance: 'Beeksisa Balansi Gad aanaa', - moderateBalance: 'Balansi Giddu gala', - goodBalance: 'Balansi Gaarii', + refill: 'Kuuponii Guuti', + confirmRefill: 'Guutuu Mirkaneessi', + currentBalance: 'Madaallii Ammaa', + lowBalance: 'Madaallii Gad-aanaa', + moderateBalance: 'Madaallii Giddu-galeessaa', + goodBalance: 'Gaarii', transactionHistory: 'Seenaa Jijjiirraa', noTransactions: 'Galmeewwan hin argamne', - enterAmount: 'Baayʼina kouponii galchi', + enterAmount: 'Lakkoofsa kuuponii galchi', validAmount: 'Baayʼina sirrii galchi', + totalDrivers: 'Waliigala Draayivaroota', + totalCoupons: 'Waliigala Kuuponoota', + lowBalanceCount: 'Madaallii Gad-aanaa', + searchPlaceholder: 'Maqaa, lakkoofsa gabatee, ykn bilbilaan barbaadi...', + refillTitle: 'Kuuponii Guuti — {0}', + phone: 'Bilbila', + plate: 'Gabatee Lakkoofsa', + status: 'Haala', + amountToAdd: 'Baayʼina Dabalamuu', + placeholder: 'Lakkoofsa kuuponii galchi', + newBalance: 'Madaallii Haaraa', + good: 'Gaarii', + moderate: 'Giddu-galeessaa', + low: 'Gad-aanaa', + couponBalance: 'Madaallii Kuuponii', + noDrivers: 'Draayivarri tokkollee hin argamne', }, settings: { - title: 'Qindaaʼinoota', - subtitle: 'Akkaawuntii fi filannoo appilikeeshinii kee tooʼadhu', - profileSettings: 'Qindaaʼinoota Profaayilii', + title: 'Qindaaʼinaa', + subtitle: 'Akaawuntii keessaniif qindaaʼina sirna Tokuma bulchaa', + profileSettings: 'Porofaayilii', notificationSettings: 'Qindaaʼinoota Beeksisa', systemSettings: 'Qindaaʼinoota Sirna', security: 'Nageenya', @@ -384,6 +1028,26 @@ const translations = { saveSystem: 'Qindaaʼinoota Sirnaa Olkaa’i', changePassword: 'Jecha darbii jijjiiri', logout: 'Baʼi', + profile: 'Porofaayilii', + yourAccountInfo: 'Odeeffannoo akaawuntii keessanii', + name: 'Maqaa', + role: 'Gahee / Qooda', + administrator: 'Bulchaa (Administrator)', + email: 'Emeelii', + pricingDispatchCommission: 'Gatii, Ergaa & Komishinii', + businessRules: 'Seera Hojii Tokuma Kallattiin (Live)', + baseFare: 'Gatii Jalqabaa (ETB)', + perKm: 'Kilomeetira Mirkanaaʼeen (ETB)', + timeBlockCharge: 'Kaffaltii Yeroo Blookii (ETB)', + timeBlockMinutes: 'Blookii Yeroo (Daqiiqaa)', + minimumFare: 'Gatii Gad-aanaa (ETB)', + dispatchRadius: 'Raadiyoosii Ergaa (km)', + maxDriversNotified: 'Draayivaroota Max Beeqsifaman', + offerTimeout: 'Yeroo Dhumate Dhiyeessii (Sekoondii)', + minCouponBalance: 'Madaallii Kuuponii Gad-aanaa', + defaultCommission: 'Komishinii Idilee (%)', + saveSettings: 'Qindaaʼinoota Sirnaa Olkaa’i', + callCenter: 'Wiirtuu Bilbilaa', }, }, }; @@ -395,23 +1059,28 @@ interface AppContextValue { setLanguage: (lang: Language) => void; theme: ThemeMode; setTheme: (theme: ThemeMode) => void; - t: (path: string, fallback?: string) => string; + t: (path: string, fallback?: string, params?: Record) => string; } const AppContext = createContext(undefined); -const getString = (lang: Language, path: string, fallback?: string) => { +const interpolateString = (value: string, params?: Record) => { + if (!params) return value; + return value.replace(/\{([^}]+)\}/g, (_, key: string) => String(params[key] ?? `{${key}}`)); +}; + +const getString = (lang: Language, path: string, fallback?: string, params?: Record) => { const keys = path.split('.'); let current: any = translations[lang]; for (const key of keys) { if (current?.[key] == null) { - return fallback ?? path; + return interpolateString(fallback ?? path, params); } current = current[key]; } - return typeof current === 'string' ? current : fallback ?? path; + return interpolateString(typeof current === 'string' ? current : fallback ?? path, params); }; export function AppProvider({ children }: { children: React.ReactNode }) { @@ -450,7 +1119,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) { setLanguage, theme, setTheme, - t: (path: string, fallback?: string) => getString(language, path, fallback), + t: (path: string, fallback?: string, params?: Record) => getString(language, path, fallback, params), }), [language, theme] ); diff --git a/src/app/lib/format.ts b/src/app/lib/format.ts index 442b6ed..677aa22 100644 --- a/src/app/lib/format.ts +++ b/src/app/lib/format.ts @@ -1,19 +1,45 @@ /** Map backend ride status values to the spec's display labels. */ -export const RIDE_STATUS_LABELS: Record = { - pending: 'Pending', - dispatched: 'Assigned', - accepted: 'Accepted', - arrived: 'Driver Arriving', - in_progress: 'In Progress', - completed: 'Completed', - cancelled: 'Cancelled', - expired: 'Expired', - unassigned: 'Unassigned', +const RIDE_STATUS_LABELS: Record> = { + en: { + pending: 'Pending', + dispatched: 'Assigned', + accepted: 'Accepted', + arrived: 'Driver Arriving', + in_progress: 'In Progress', + completed: 'Completed', + cancelled: 'Cancelled', + expired: 'Expired', + unassigned: 'Unassigned', + }, + am: { + pending: 'በሂደት ላይ ያለ', + dispatched: 'የተመደበ', + accepted: 'የተቀበለው', + arrived: 'አሽከርካሪው እየደረሰ ነው', + in_progress: 'በጉዞ ላይ ያለ', + completed: 'የተጠናቀቀ', + cancelled: 'የተሰረዘ', + expired: 'ጊዜው ያለፈበት', + unassigned: 'ያልተመደበ', + }, + om: { + pending: 'Eeggamaa Jira', + dispatched: 'Ramadameera', + accepted: 'Fudhatameera', + arrived: 'Draayivarri Dhiyaachaa Jira', + in_progress: 'Adeemsa Irra Jira', + completed: 'Xumurameera', + cancelled: 'Haqameera', + expired: 'Yeroon Isa Jalaa Dhumateera', + unassigned: 'Ramaddii Malee', + }, }; export function rideStatusLabel(status?: string): string { if (!status) return 'Unknown'; - return RIDE_STATUS_LABELS[status] ?? status; + const lang = typeof window !== 'undefined' ? window.localStorage.getItem('language') : null; + const labels = lang === 'am' ? RIDE_STATUS_LABELS.am : lang === 'om' ? RIDE_STATUS_LABELS.om : RIDE_STATUS_LABELS.en; + return labels[status] ?? RIDE_STATUS_LABELS.en[status] ?? status; } export function formatETB(amount?: number | null): string { diff --git a/src/app/pages/Analytics.tsx b/src/app/pages/Analytics.tsx index 09de40e..6c3192e 100644 --- a/src/app/pages/Analytics.tsx +++ b/src/app/pages/Analytics.tsx @@ -6,8 +6,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { toast } from 'sonner'; import { api } from '../lib/api'; import { formatETB } from '../lib/format'; +import { useAppContext } from '../contexts/AppContext'; export default function Analytics() { + const { t } = useAppContext(); const [timeRange, setTimeRange] = useState('today'); const [stats, setStats] = useState(null); const [operators, setOperators] = useState([]); @@ -31,26 +33,26 @@ export default function Analytics() { const pct = (n: number, d: number) => (d > 0 ? Math.round((n / d) * 100) : 0); const metrics = [ - { label: 'Total Rides', value: stats.totalRides ?? 0, icon: Car, color: '#00BDC3' }, - { label: 'Total Revenue', value: formatETB(stats.totalRevenue), icon: DollarSign, color: '#10B981' }, - { label: 'Active Drivers', value: `${stats.activeDrivers ?? 0}/${stats.totalDrivers ?? 0}`, icon: Users, color: '#00BDC3' }, - { label: 'Total Calls', value: stats.totalCalls ?? 0, icon: PhoneCall, color: '#00BDC3' }, + { label: t('analytics.totalRides', 'Total Rides'), value: stats.totalRides ?? 0, icon: Car, color: '#00BDC3' }, + { label: t('analytics.totalRevenue', 'Total Revenue'), value: formatETB(stats.totalRevenue), icon: DollarSign, color: '#10B981' }, + { label: t('analytics.activeDrivers', 'Active Drivers'), value: `${stats.activeDrivers ?? 0}/${stats.totalDrivers ?? 0}`, icon: Users, color: '#00BDC3' }, + { label: t('analytics.totalCalls', 'Total Calls'), value: stats.totalCalls ?? 0, icon: PhoneCall, color: '#00BDC3' }, ]; return (
-

Analytics & Reports

-

Overview of dispatch performance

+

{t('analytics.title', 'Analytics & Reports')}

+

{t('analytics.subtitle', 'Overview of dispatch performance')}

@@ -71,32 +73,32 @@ export default function Analytics() {
-

Completed Rides

{stats.completedRides ?? 0}

{pct(stats.completedRides, stats.totalRides)}% of total

+

{t('analytics.completedRides', 'Completed Rides')}

{stats.completedRides ?? 0}

{t('analytics.completedPercent', '{0}% of total', { 0: pct(stats.completedRides, stats.totalRides) })}

-

Cancelled Rides

{stats.cancelledRides ?? 0}

{pct(stats.cancelledRides, stats.totalRides)}% of total

+

{t('analytics.cancelledRides', 'Cancelled Rides')}

{stats.cancelledRides ?? 0}

{t('analytics.completedPercent', '{0}% of total', { 0: pct(stats.cancelledRides, stats.totalRides) })}

-

Active Rides

{stats.activeRides ?? 0}

Avg fare {formatETB(stats.averageFare)}

+

{t('analytics.activeRides', 'Active Rides')}

{stats.activeRides ?? 0}

{t('analytics.avgFare', 'Avg fare {0}', { 0: formatETB(stats.averageFare) })}

- Operator Performance + {t('analytics.operatorPerformance', 'Operator Performance')}
- {['Operator', 'Status', 'Shift', 'Rides Created', 'Total Calls'].map((h) => ( + {[t('analytics.operator', 'Operator'), t('analytics.status', 'Status'), t('analytics.shift', 'Shift'), t('analytics.ridesCreated', 'Rides Created'), t('analytics.totalCalls', 'Total Calls')].map((h) => ( ))} @@ -105,14 +107,14 @@ export default function Analytics() { {operators.map((op) => ( - - + + ))} {operators.length === 0 && ( - + )}
{h}
{op.name}{op.status}{op.shift}{op.status === 'active' ? t('analytics.active', 'active') : op.status}{op.shift === 'morning' ? t('analytics.morning', 'Morning') : op.shift} {op.totalRidesCreated ?? 0} {(op.totalCalls ?? 0).toLocaleString()}
No operators yet
{t('analytics.noOperators', 'No operators yet')}
diff --git a/src/app/pages/CallLogs.tsx b/src/app/pages/CallLogs.tsx index eaf73fe..14f74ba 100644 --- a/src/app/pages/CallLogs.tsx +++ b/src/app/pages/CallLogs.tsx @@ -7,6 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { toast } from 'sonner'; import { api } from '../lib/api'; import LogCallDialog from '../components/LogCallDialog'; +import { useAppContext } from '../contexts/AppContext'; const formatDuration = (seconds: number) => `${Math.floor(seconds / 60)}m ${seconds % 60}s`; const statusColor = (status: string) => @@ -19,6 +20,7 @@ const statusIcon = (status: string) => status === 'completed' ? : status === 'missed' ? : ; export default function CallLogs() { + const { t } = useAppContext(); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); @@ -52,18 +54,18 @@ export default function CallLogs() {
-

Call Logs

-

Detailed history of all customer calls

+

{t('callLogs.title', 'Call Logs')}

+

{t('callLogs.subtitle', 'Detailed history of all customer calls')}

{[ - { label: 'Total Calls', value: filtered.length, icon: Phone, color: '#00BDC3' }, - { label: 'Completed', value: completed, icon: CheckCircle, color: '#10B981' }, - { label: 'Missed', value: missed, icon: XCircle, color: '#EF4444' }, - { label: 'Avg Duration', value: formatDuration(avg), icon: Clock, color: '#00BDC3' }, + { label: t('callLogs.totalCalls', 'Total Calls'), value: filtered.length, icon: Phone, color: '#00BDC3' }, + { label: t('callLogs.completed', 'Completed'), value: completed, icon: CheckCircle, color: '#10B981' }, + { label: t('callLogs.missed', 'Missed'), value: missed, icon: XCircle, color: '#EF4444' }, + { label: t('callLogs.avgDuration', 'Avg Duration'), value: formatDuration(avg), icon: Clock, color: '#00BDC3' }, ].map((s) => ( @@ -81,15 +83,15 @@ export default function CallLogs() {
- setSearchQuery(e.target.value)} className="pl-10 h-10" /> + setSearchQuery(e.target.value)} className="pl-10 h-10" />
@@ -97,7 +99,7 @@ export default function CallLogs() {
- Call History + {t('callLogs.history', 'Call History')} {loading ? (
@@ -106,7 +108,7 @@ export default function CallLogs() { - {['Time', 'Customer', 'Phone', 'Operator', 'Status', 'Duration', 'Ride ID', 'Notes'].map((h) => ( + {[t('callLogs.time', 'Time'), t('callLogs.customer', 'Customer'), t('callLogs.phone', 'Phone'), t('callLogs.operator', 'Operator'), t('callLogs.status', 'Status'), t('callLogs.duration', 'Duration'), t('callLogs.rideId', 'Ride ID'), t('callLogs.notes', 'Notes')].map((h) => ( ))} @@ -118,7 +120,7 @@ export default function CallLogs() { - + @@ -129,7 +131,7 @@ export default function CallLogs() { )} {!loading && filtered.length === 0 && ( -

No call logs found

+

{t('callLogs.noLogs', 'No call logs found')}

)} diff --git a/src/app/pages/Coupons.tsx b/src/app/pages/Coupons.tsx index f78dd84..1865749 100644 --- a/src/app/pages/Coupons.tsx +++ b/src/app/pages/Coupons.tsx @@ -8,6 +8,7 @@ import { Phone, Car, Search, Wallet, Plus, Loader2, AlertTriangle } from 'lucide import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../components/ui/dialog'; import { toast } from 'sonner'; import { api } from '../lib/api'; +import { useAppContext } from '../contexts/AppContext'; const couponBadge = (b: number) => b < 15 @@ -21,6 +22,7 @@ const statusBadge = (s: string) => 'bg-gray-500 text-white'; export default function Coupons() { + const { t } = useAppContext(); const [drivers, setDrivers] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); @@ -93,17 +95,17 @@ export default function Coupons() { {/* Header */}
-

Coupon Management

-

Manage driver coupon balances

+

{t('coupons.title', 'Coupon Management')}

+

{t('coupons.subtitle', 'Manage driver coupon balances')}

{/* Stats */}
{[ - { label: 'Total Drivers', value: drivers.length, color: '#00BDC3', icon: Car }, - { label: 'Total Coupons', value: totalCoupons, color: '#10B981', icon: Wallet }, - { label: 'Low Balance', value: lowBalance, color: '#EF4444', icon: AlertTriangle }, + { label: t('coupons.totalDrivers', 'Total Drivers'), value: drivers.length, color: '#00BDC3', icon: Car }, + { label: t('coupons.totalCoupons', 'Total Coupons'), value: totalCoupons, color: '#10B981', icon: Wallet }, + { label: t('coupons.lowBalanceCount', 'Low Balance'), value: lowBalance, color: '#EF4444', icon: AlertTriangle }, ].map((s) => ( @@ -127,7 +129,7 @@ export default function Coupons() {
setSearchQuery(e.target.value)} className="pl-10 h-10" @@ -172,11 +174,11 @@ export default function Coupons() {
-

Coupon Balance

+

{t('coupons.couponBalance', 'Coupon Balance')}

{balance} - {balance < 15 ? 'Low' : balance < 30 ? 'Moderate' : 'Good'} + {balance < 15 ? t('coupons.low', 'Low') : balance < 30 ? t('coupons.moderate', 'Moderate') : t('coupons.good', 'Good')}
@@ -186,7 +188,7 @@ export default function Coupons() { size="sm" onClick={() => setRefillDriver(d)} > - Refill Coupons + {t('coupons.refill', 'Refill Coupons')}
@@ -197,7 +199,7 @@ export default function Coupons() { ) : (
-

No drivers found

+

{t('coupons.noDrivers', 'No drivers found')}

)} @@ -205,35 +207,35 @@ export default function Coupons() { !open && closeRefill()}> - Refill Coupons — {refillDriver?.name} + {t('coupons.refillTitle', 'Refill Coupons — {0}', { 0: refillDriver?.name ?? '' })}
-

Phone

+

{t('coupons.phone', 'Phone')}

{refillDriver?.phone}

-

Plate

+

{t('coupons.plate', 'Plate')}

{refillDriver?.licensePlate}

-

Current Balance

+

{t('coupons.currentBalance', 'Current Balance')}

{refillDriver?.couponBalance ?? 0}

-

Status

+

{t('coupons.status', 'Status')}

{refillDriver?.status}
- + setRefillAmount(e.target.value)} - placeholder="Enter number of coupons" + placeholder={t('coupons.placeholder', 'Enter number of coupons')} min="1" autoFocus /> @@ -241,7 +243,7 @@ export default function Coupons() { {refillAmount && parseInt(refillAmount) > 0 && (
-

New Balance

+

{t('coupons.newBalance', 'New Balance')}

{(refillDriver?.couponBalance ?? 0) + parseInt(refillAmount)} coupons

@@ -249,9 +251,9 @@ export default function Coupons() { )}
- +
diff --git a/src/app/pages/Dashboard.tsx b/src/app/pages/Dashboard.tsx index ffbc7e2..76acb3e 100644 --- a/src/app/pages/Dashboard.tsx +++ b/src/app/pages/Dashboard.tsx @@ -12,6 +12,7 @@ import { rideStatusLabel } from '../lib/format'; import GebetaMapView from '../components/GebetaMapView'; import LogCallDialog from '../components/LogCallDialog'; import NewRideDialog from '../components/NewRideDialog'; +import { useAppContext } from '../contexts/AppContext'; const ACTIVE_STATUSES = ['dispatched', 'accepted', 'arrived', 'in_progress']; @@ -31,6 +32,7 @@ const statusBadge = (status: string) => { export default function Dashboard() { const navigate = useNavigate(); + const { t } = useAppContext(); const [rides, setRides] = useState([]); const [drivers, setDrivers] = useState([]); const [loading, setLoading] = useState(true); @@ -148,7 +150,7 @@ export default function Dashboard() {
@@ -160,7 +162,7 @@ export default function Dashboard() { <> {/* Pending */}
-

Pending Rides ({pendingRides.length})

+

{t('dashboard.pendingRides', 'Pending Rides')} ({pendingRides.length})

{pendingRides.map((ride) => ( @@ -186,10 +188,10 @@ export default function Dashboard() {
))} - {pendingRides.length === 0 &&

No pending rides

} + {pendingRides.length === 0 &&

{t('dashboard.noPendingRides', 'No pending rides')}

}
{/* Active */}
-

Active Rides ({activeRides.length})

+

{t('dashboard.activeRides', 'Active Rides')} ({activeRides.length})

{activeRides.map((ride) => ( @@ -224,12 +226,12 @@ export default function Dashboard() { {rideStatusLabel(ride.status)}
))} - {activeRides.length === 0 &&

No active rides

} + {activeRides.length === 0 &&

{t('dashboard.noActiveRides', 'No active rides')}

}
@@ -242,13 +244,13 @@ export default function Dashboard() {
-

Driver Status

- Live +

{t('dashboard.driverStatus', 'Driver Status')}

+ {t('dashboard.live', 'Live')}
-
Available ({drivers.filter((d) => d.status === 'available').length})
-
Busy ({drivers.filter((d) => d.status === 'busy').length})
-
Offline ({drivers.filter((d) => d.status === 'offline').length})
+
{t('dashboard.available', 'Available')} ({drivers.filter((d) => d.status === 'available').length})
+
{t('dashboard.busy', 'Busy')} ({drivers.filter((d) => d.status === 'busy').length})
+
{t('dashboard.offline', 'Offline')} ({drivers.filter((d) => d.status === 'offline').length})
@@ -257,7 +259,7 @@ export default function Dashboard() { !open && setAssignFor(null)}> - Assign Driver to {assignFor?.customerName} + {t('dashboard.assignDriverTo', 'Assign Driver to {0}', { 0: assignFor?.customerName ?? '' })}
{availableDrivers.map((driver) => ( @@ -282,7 +284,7 @@ export default function Dashboard() { ))} - {availableDrivers.length === 0 &&

No available drivers

} + {availableDrivers.length === 0 &&

{t('dashboard.noAvailableDrivers', 'No available drivers')}

}
diff --git a/src/app/pages/Drivers.tsx b/src/app/pages/Drivers.tsx index 699309e..57092f1 100644 --- a/src/app/pages/Drivers.tsx +++ b/src/app/pages/Drivers.tsx @@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { toast } from 'sonner'; import { api } from '../lib/api'; import { connectSocket, getSocket } from '../lib/socket'; +import { useAppContext } from '../contexts/AppContext'; const emptyNew = { name: '', phone: '', email: '', vehicleType: 'sedan', vehicleModel: '', @@ -19,6 +20,7 @@ const emptyNew = { export default function Drivers() { const navigate = useNavigate(); + const { t } = useAppContext(); const [drivers, setDrivers] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); @@ -138,12 +140,12 @@ export default function Drivers() {
-

Driver Management

-

Manage your driver fleet

+

{t('drivers.title', 'Driver Management')}

+

{t('drivers.subtitle', 'Manage your driver fleet')}

- Live Auto-Refreshing + {t('drivers.liveRefreshing', 'Live Auto-Refreshing')}
@@ -151,25 +153,25 @@ export default function Drivers() {
- setSearchQuery(e.target.value)} className="pl-10" /> + setSearchQuery(e.target.value)} className="pl-10" />
- Add New Driver + {t('drivers.addTitle', 'Add New Driver')}
-
setForm({ ...form, name: e.target.value })} />
+
setForm({ ...form, name: e.target.value })} />
-
setForm({ ...form, phone: e.target.value })} placeholder="0911…" />
-
setForm({ ...form, email: e.target.value })} placeholder="driver@…" />
+
setForm({ ...form, phone: e.target.value })} placeholder="0911…" />
+
setForm({ ...form, email: e.target.value })} placeholder="driver@…" />
- +
-
setForm({ ...form, vehicleModel: e.target.value })} placeholder="Toyota Corolla" />
+
setForm({ ...form, vehicleModel: e.target.value })} placeholder="Toyota Corolla" />
-
setForm({ ...form, licensePlate: e.target.value })} placeholder="AA-12345" />
-
setForm({ ...form, licenseNumber: e.target.value })} placeholder="DL-…" />
+
setForm({ ...form, licensePlate: e.target.value })} placeholder="AA-12345" />
+
setForm({ ...form, licenseNumber: e.target.value })} placeholder="DL-…" />
-
setForm({ ...form, couponBalance: parseInt(e.target.value) || 0 })} />
+
setForm({ ...form, couponBalance: parseInt(e.target.value) || 0 })} />
- +
@@ -200,9 +202,9 @@ export default function Drivers() {
-

Total Drivers

{drivers.length}

-

Available

{drivers.filter((d) => d.status === 'available').length}

-

Busy

{drivers.filter((d) => d.status === 'busy').length}

+

{t('drivers.totalDrivers', 'Total Drivers')}

{drivers.length}

+

{t('drivers.available', 'Available')}

{drivers.filter((d) => d.status === 'available').length}

+

{t('drivers.busy', 'Busy')}

{drivers.filter((d) => d.status === 'busy').length}

@@ -212,13 +214,13 @@ export default function Drivers() {
{h}
{log.customerName ?? '—'} {log.customerPhone} {log.operatorName}{statusIcon(log.status)}{log.status}{statusIcon(log.status)}{t(`callLogs.${log.status}`, log.status)} {formatDuration(log.duration ?? 0)} {log.rideId ? {String(log.rideId).slice(0, 8)} : } {log.notes ?? '—'}
- Driver Name - Phone - Vehicle - License Plate - Coupon Balance - Status - Actions + {t('drivers.driverName', 'Driver Name')} + {t('drivers.phone', 'Phone')} + {t('drivers.vehicle', 'Vehicle')} + {t('drivers.licensePlate', 'License Plate')} + {t('drivers.couponBalance', 'Coupon Balance')} + {t('drivers.status', 'Status')} + {t('drivers.actions', 'Actions')} @@ -228,7 +230,7 @@ export default function Drivers() { {d.phone} {d.vehicleType ?? d.vehicleModel ?? '—'} {d.licensePlate} - {d.couponBalance} coupons + {d.couponBalance} {t('drivers.coupons', 'coupons')} {d.status}
@@ -241,24 +243,24 @@ export default function Drivers() {
)} - {!loading && visible.length === 0 &&

No drivers found

} + {!loading && visible.length === 0 &&

{t('drivers.noDrivers', 'No drivers found')}

}
{/* Edit dialog */} !o && setEditing(null)}> - Edit Driver + {t('drivers.edit', 'Edit Driver')}
-
setEdit({ ...edit, name: e.target.value })} />
-
setEdit({ ...edit, phone: e.target.value })} />
-
setEdit({ ...edit, vehicleModel: e.target.value })} />
-
setEdit({ ...edit, licensePlate: e.target.value })} />
-
setEdit({ ...edit, commissionPercent: parseFloat(e.target.value) || 0 })} />
+
setEdit({ ...edit, name: e.target.value })} />
+
setEdit({ ...edit, phone: e.target.value })} />
+
setEdit({ ...edit, vehicleModel: e.target.value })} />
+
setEdit({ ...edit, licensePlate: e.target.value })} />
+
setEdit({ ...edit, commissionPercent: parseFloat(e.target.value) || 0 })} />
- +
diff --git a/src/app/pages/Operators.tsx b/src/app/pages/Operators.tsx index 0029241..e716c6c 100644 --- a/src/app/pages/Operators.tsx +++ b/src/app/pages/Operators.tsx @@ -9,10 +9,12 @@ import { Label } from '../components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select'; import { toast } from 'sonner'; import { api } from '../lib/api'; +import { useAppContext } from '../contexts/AppContext'; const statusColor = (active: boolean) => (active ? 'bg-[#10B981] text-white' : 'bg-[#6B7280] text-white'); export default function Operators() { + const { t } = useAppContext(); const [operators, setOperators] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); @@ -52,18 +54,18 @@ export default function Operators() {
-

Operators Management

-

Manage call center operators and track performance

+

{t('operators.title', 'Operators Management')}

+

{t('operators.subtitle', 'Manage call center operators and track performance')}

{[ - { label: 'Total Operators', value: operators.length, icon: UserCog, color: '#00BDC3' }, - { label: 'Online Now', value: onlineCount, icon: Users, color: '#10B981' }, - { label: 'Total Rides Created', value: totalCustomers, icon: Users, color: '#00BDC3' }, - { label: 'Total Calls', value: totalCalls, icon: PhoneCall, color: '#00BDC3' }, + { label: t('operators.totalOperators', 'Total Operators'), value: operators.length, icon: UserCog, color: '#00BDC3' }, + { label: t('operators.onlineNow', 'Online Now'), value: onlineCount, icon: Users, color: '#10B981' }, + { label: t('operators.totalRidesCreated', 'Total Rides Created'), value: totalCustomers, icon: Users, color: '#00BDC3' }, + { label: t('operators.totalCalls', 'Total Calls'), value: totalCalls, icon: PhoneCall, color: '#00BDC3' }, ].map((s) => ( @@ -80,7 +82,7 @@ export default function Operators() {
- setSearchQuery(e.target.value)} className="pl-10 h-10" /> + setSearchQuery(e.target.value)} className="pl-10 h-10" />
@@ -99,15 +101,15 @@ export default function Operators() {
{op.name}

{op.shift}

- {op.status === 'active' ? 'Active' : 'Inactive'} + {op.status === 'active' ? t('operators.active', 'Active') : t('operators.inactive', 'Inactive')}
{op.email}
{op.phone}
-

Total Calls

{(op.totalCalls ?? 0).toLocaleString()}

-

Rides Created

{(op.totalRidesCreated ?? 0).toLocaleString()}

+

{t('operators.totalCalls', 'Total Calls')}

{(op.totalCalls ?? 0).toLocaleString()}

+

{t('operators.totalRidesCreated', 'Total Rides Created')}

{(op.totalRidesCreated ?? 0).toLocaleString()}

@@ -116,13 +118,14 @@ export default function Operators() { )} {!loading && visible.length === 0 && ( -

No operators found

+

{t('operators.noOperators', 'No operators found')}

)}
); } function AddOperatorDialog({ onCreated }: { onCreated: () => void }) { + const { t } = useAppContext(); const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [form, setForm] = useState({ name: '', email: '', phone: '', password: '', shift: 'morning' }); @@ -150,30 +153,30 @@ function AddOperatorDialog({ onCreated }: { onCreated: () => void }) { return ( - + - Add New OperatorAdd a new call center operator to the system + {t('operators.addNewTitle', 'Add New Operator')}{t('operators.addNewDescription', 'Add a new call center operator to the system')}
-
setForm({ ...form, name: e.target.value })} placeholder="John Doe" required />
-
setForm({ ...form, email: e.target.value })} placeholder="john@tokuma.et" required />
-
setForm({ ...form, phone: e.target.value })} placeholder="0911 123456" required />
-
setForm({ ...form, password: e.target.value })} placeholder="••••••••" required />
+
setForm({ ...form, name: e.target.value })} placeholder="John Doe" required />
+
setForm({ ...form, email: e.target.value })} placeholder="john@tokuma.et" required />
+
setForm({ ...form, phone: e.target.value })} placeholder="0911 123456" required />
+
setForm({ ...form, password: e.target.value })} placeholder="••••••••" required />
- +
- +
diff --git a/src/app/pages/RideTracking.tsx b/src/app/pages/RideTracking.tsx index 2f479a1..8eef5e4 100644 --- a/src/app/pages/RideTracking.tsx +++ b/src/app/pages/RideTracking.tsx @@ -12,6 +12,7 @@ import { connectSocket, subscribeRide, unsubscribeRide } from '../lib/socket'; import { rideStatusLabel, formatETB } from '../lib/format'; import type { RoadRoute } from '../lib/route'; import GebetaMapView, { type MapPoint } from '../components/GebetaMapView'; +import { useAppContext } from '../contexts/AppContext'; const rideStatuses = ['pending', 'dispatched', 'accepted', 'arrived', 'in_progress', 'completed']; @@ -31,6 +32,7 @@ const getStatusColor = (status: string) => { export default function RideTracking() { const { rideId } = useParams(); const navigate = useNavigate(); + const { t } = useAppContext(); const [ride, setRide] = useState(null); const [loading, setLoading] = useState(true); @@ -127,9 +129,9 @@ export default function RideTracking() { return (
-

Ride not found

+

{t('rides.rideNotFound', 'Ride not found')}

@@ -156,8 +158,8 @@ export default function RideTracking() {
-

Ride #{String(ride.id).slice(0, 8)}

-

Track ride progress in real-time

+

{t('rides.rideTitle', 'Ride #{0}', { 0: String(ride.id).slice(0, 8) })}

+

{t('rides.subtitle', 'Track ride progress in real-time')}

@@ -167,7 +169,7 @@ export default function RideTracking() { - Ride Progress + {t('rides.rideProgress', 'Ride Progress')}
@@ -204,12 +206,12 @@ export default function RideTracking() { - Customer Information + {t('rides.customerInformation', 'Customer Information')}
-

Name

+

{t('rides.name', 'Name')}

{ride.customerName}

@@ -221,14 +223,14 @@ export default function RideTracking() {
-

Pickup

+

{t('rides.pickup', 'Pickup')}

{ride.pickupLocation}

-

Destination

+

{t('rides.destination', 'Destination')}

{ride.dropoffLocation}

@@ -239,39 +241,39 @@ export default function RideTracking() { - Driver Information + {t('rides.driverInformation', 'Driver Information')} {hasDriver ? (
-

Driver Name

+

{t('rides.driverName', 'Driver Name')}

{ride.driverName}

-

Phone Number

+

{t('rides.phoneNumber', 'Phone Number')}

{ride.driverPhone ?? '—'}

-

Vehicle

+

{t('rides.vehicle', 'Vehicle')}

{ride.vehicleInfo ?? '—'}

-

License Plate

+

{t('rides.licensePlate', 'License Plate')}

{ride.licensePlate ?? '—'}

{ride.driverPhone && ( )}
) : (
-

No driver assigned yet

+

{t('rides.noDriverAssigned', 'No driver assigned yet')}

)}
@@ -280,21 +282,21 @@ export default function RideTracking() { - Ride Details + {t('rides.rideDetails', 'Ride Details')}
-

Ride ID

+

{t('rides.rideIdLabel', 'Ride ID')}

#{String(ride.id).slice(0, 8)}

-

Created

+

{t('rides.createdAt', 'Created')}

{format(new Date(ride.createdAt), 'MMM dd, HH:mm')}

-

Updated

+

{t('rides.updated', 'Updated')}

{format(new Date(ride.updatedAt), 'MMM dd, HH:mm')}

@@ -302,7 +304,7 @@ export default function RideTracking() {
-

Road distance

+

{t('rides.roadDistance', 'Road distance')}

{Number(displayDistanceKm).toFixed(2)} km {displayDurationMin != null ? ` · ~${displayDurationMin} min` : ''} @@ -311,7 +313,7 @@ export default function RideTracking() {

)}
-

{ride.status === 'completed' ? 'Final Fare' : 'Fare'}

+

{ride.status === 'completed' ? t('rides.finalFare', 'Final Fare') : t('rides.fare', 'Fare')}

{formatETB(ride.fare)}

@@ -323,7 +325,7 @@ export default function RideTracking() { className="w-full border-[#EF4444] text-[#EF4444] hover:bg-[#EF4444] hover:text-white" > {cancelling ? : } - Cancel Ride + {t('rides.cancelRide', 'Cancel Ride')} )} @@ -332,7 +334,7 @@ export default function RideTracking() { - Live Tracking + {t('rides.liveTracking', 'Live Tracking')} ([]); const [pagination, setPagination] = useState({ page: 1, totalPages: 1, total: 0 }); const [loading, setLoading] = useState(true); @@ -117,32 +119,32 @@ export default function Rides() { return (
-

Ride History

-

View and manage all ride records

+

{t('rides.title', 'Ride History')}

+

{t('rides.subtitle', 'View and manage all ride records')}

- { setSearchQuery(e.target.value); setPage(1); }} className="pl-10" /> + { setSearchQuery(e.target.value); setPage(1); }} className="pl-10" />
- +
@@ -156,7 +158,7 @@ export default function Rides() { - {['Ride ID', 'Customer', 'Pickup', 'Dropoff', 'Driver', 'Status', 'Time', 'Fare', 'Actions'].map((h) => ( + {[t('rides.rideId', 'Ride ID'), t('rides.customer', 'Customer'), t('rides.pickup', 'Pickup'), t('rides.dropoff', 'Dropoff'), t('rides.driver', 'Driver'), t('rides.status', 'Status'), t('rides.time', 'Time'), t('rides.fare', 'Fare'), t('rides.actions', 'Actions')].map((h) => ( {h} ))} @@ -168,13 +170,13 @@ export default function Rides() {

{ride.customerName}

{ride.customerPhone}

{ride.pickupLocation}

{ride.dropoffLocation}

- {ride.driverName ?

{ride.driverName}

:

Not assigned

}
+ {ride.driverName ?

{ride.driverName}

:

{t('rides.notAssigned', 'Not assigned')}

}
{rideStatusLabel(ride.status)} {format(new Date(ride.createdAt), 'MMM dd, HH:mm')} {ride.fare != null ? formatETB(ride.fare) : '-'}
- + {PENDING_STATES.includes(ride.status) && (
)} - {!loading && visible.length === 0 &&

No rides found

} + {!loading && visible.length === 0 &&

{t('rides.noRides', 'No rides found')}

}
-

Showing {visible.length} of {pagination.total} rides

+

{t('rides.showingCount', 'Showing {0} of {1} rides', { 0: visible.length, 1: pagination.total })}

- + - +
diff --git a/src/app/pages/Settings.tsx b/src/app/pages/Settings.tsx index ce08ea9..02b07a1 100644 --- a/src/app/pages/Settings.tsx +++ b/src/app/pages/Settings.tsx @@ -7,23 +7,25 @@ import { Loader2 } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '../lib/api'; import { useAuth } from '../contexts/AuthContext'; +import { useAppContext } from '../contexts/AppContext'; // The configurable Tokuma business settings surfaced for editing. -const EDITABLE: { key: string; label: string; hint?: string }[] = [ - { key: 'fare.baseFare', label: 'Base Fare (ETB)' }, - { key: 'fare.perKm', label: 'Per Kilometre (ETB)' }, - { key: 'fare.timeBlockCharge', label: 'Time Block Charge (ETB)' }, - { key: 'fare.timeBlockMinutes', label: 'Time Block (minutes)' }, - { key: 'fare.minimumFare', label: 'Minimum Fare (ETB)' }, - { key: 'dispatch.radiusKm', label: 'Dispatch Radius (km)' }, - { key: 'dispatch.maxDrivers', label: 'Max Drivers Notified' }, - { key: 'dispatch.offerTtlSeconds', label: 'Offer Timeout (seconds)' }, - { key: 'coupon.minBalanceThreshold', label: 'Min Coupon Balance' }, - { key: 'commission.defaultPercent', label: 'Default Commission (%)' }, +const EDITABLE: { key: string; labelKey: string }[] = [ + { key: 'fare.baseFare', labelKey: 'settings.baseFare' }, + { key: 'fare.perKm', labelKey: 'settings.perKm' }, + { key: 'fare.timeBlockCharge', labelKey: 'settings.timeBlockCharge' }, + { key: 'fare.timeBlockMinutes', labelKey: 'settings.timeBlockMinutes' }, + { key: 'fare.minimumFare', labelKey: 'settings.minimumFare' }, + { key: 'dispatch.radiusKm', labelKey: 'settings.dispatchRadius' }, + { key: 'dispatch.maxDrivers', labelKey: 'settings.maxDriversNotified' }, + { key: 'dispatch.offerTtlSeconds', labelKey: 'settings.offerTimeout' }, + { key: 'coupon.minBalanceThreshold', labelKey: 'settings.minCouponBalance' }, + { key: 'commission.defaultPercent', labelKey: 'settings.defaultCommission' }, ]; export default function Settings() { const { user, role } = useAuth(); + const { t } = useAppContext(); const [values, setValues] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -59,32 +61,32 @@ export default function Settings() { return (
-

Settings

-

Manage your account and Tokuma system configuration

+

{t('settings.title', 'Settings')}

+

{t('settings.subtitle', 'Manage your account and Tokuma system configuration')}

{/* Profile */} - Profile - Your account information + {t('settings.profile', 'Profile')} + {t('settings.yourAccountInfo', 'Your account information')}
-
-
+
+
-
+
{/* System (Pricing / Dispatch / Commission) */} - Pricing, Dispatch & Commission + {t('settings.pricingDispatchCommission', 'Pricing, Dispatch & Commission')} - Live Tokuma business rules{role !== 'admin' ? ' (read-only — admin access required to edit)' : ''}. + {t('settings.businessRules', 'Live Tokuma business rules')}{role !== 'admin' ? ` (${t('settings.readOnly', 'read-only — admin access required to edit')})` : ''}. @@ -93,9 +95,9 @@ export default function Settings() { ) : ( <>
- {EDITABLE.map(({ key, label }) => ( + {EDITABLE.map(({ key, labelKey }) => (
- + {role === 'admin' && ( )}