aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-06-24 16:14:28 +0200
committerAriel Costas Guerrero <ariel@costas.dev>2025-06-24 16:14:28 +0200
commitecb73e1684b42265af3f8d93541600e4d0f9c414 (patch)
tree26e413973b32de0367aa06cfc0df329c67733821
parentf65b4e1e0d5648038823962349279be4badc68ed (diff)
Implement i18n
Closes #18
-rw-r--r--package-lock.json100
-rw-r--r--package.json7
-rw-r--r--src/frontend/app/i18n/index.ts31
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json46
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json46
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json46
-rw-r--r--src/frontend/app/root.css1
-rw-r--r--src/frontend/app/root.tsx2
-rw-r--r--src/frontend/app/routes/estimates-$id.tsx2
-rw-r--r--src/frontend/app/routes/map.tsx2
-rw-r--r--src/frontend/app/routes/settings.tsx69
-rw-r--r--src/frontend/app/routes/stoplist.tsx16
12 files changed, 324 insertions, 44 deletions
diff --git a/package-lock.json b/package-lock.json
index 07400f9..07872b2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,11 @@
"workspaces": [
"src/frontend"
],
+ "dependencies": {
+ "i18next": "^25.2.1",
+ "i18next-browser-languagedetector": "^8.2.0",
+ "react-i18next": "^15.5.3"
+ },
"devDependencies": {
"concurrently": "^9.1.2",
"prettier": "^3.5.3"
@@ -451,6 +456,15 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.27.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
+ "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -4092,6 +4106,15 @@
"node": ">=12"
}
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -4109,6 +4132,46 @@
"node": ">= 0.8"
}
},
+ "node_modules/i18next": {
+ "version": "25.2.1",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.2.1.tgz",
+ "integrity": "sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://locize.com"
+ },
+ {
+ "type": "individual",
+ "url": "https://locize.com/i18next.html"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.27.1"
+ },
+ "peerDependencies": {
+ "typescript": "^5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/i18next-browser-languagedetector": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
+ "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -5342,6 +5405,32 @@
"react": "^19.1.0"
}
},
+ "node_modules/react-i18next": {
+ "version": "15.5.3",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.3.tgz",
+ "integrity": "sha512-ypYmOKOnjqPEJZO4m1BI0kS8kWqkBNsKYyhVUfij0gvjy9xJNoG/VcGkxq5dRlVwzmrmY1BQMAmpbbUBLwC4Kw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.27.6",
+ "html-parse-stringify": "^3.0.1"
+ },
+ "peerDependencies": {
+ "i18next": ">= 23.2.3",
+ "react": ">= 16.8.0",
+ "typescript": "^5"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
@@ -6262,7 +6351,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -6615,6 +6704,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/vt-pbf": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
diff --git a/package.json b/package.json
index 81ba10f..24814f6 100644
--- a/package.json
+++ b/package.json
@@ -18,5 +18,10 @@
},
"workspaces": [
"src/frontend"
- ]
+ ],
+ "dependencies": {
+ "i18next": "^25.2.1",
+ "i18next-browser-languagedetector": "^8.2.0",
+ "react-i18next": "^15.5.3"
+ }
}
diff --git a/src/frontend/app/i18n/index.ts b/src/frontend/app/i18n/index.ts
new file mode 100644
index 0000000..a7ba6aa
--- /dev/null
+++ b/src/frontend/app/i18n/index.ts
@@ -0,0 +1,31 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+import LanguageDetector from 'i18next-browser-languagedetector';
+import esES from './locales/es-ES.json';
+import glES from './locales/gl-ES.json';
+import enGB from './locales/en-GB.json';
+
+// Add more languages as needed
+const resources = {
+ 'es-ES': { translation: esES },
+ 'gl-ES': { translation: glES },
+ 'en-GB': { translation: enGB },
+};
+
+i18n
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ resources,
+ fallbackLng: 'es-ES',
+ interpolation: {
+ escapeValue: false,
+ },
+ supportedLngs: ['es-ES', 'gl-ES', 'en-GB'],
+ detection: {
+ order: ['querystring', 'cookie', 'localStorage', 'navigator', 'htmlTag'],
+ caches: ['localStorage', 'cookie'],
+ },
+ });
+
+export default i18n;
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
new file mode 100644
index 0000000..f53a802
--- /dev/null
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -0,0 +1,46 @@
+{
+ "about": {
+ "title": "About UrbanoVigo Web",
+ "description": "Web app to find stops and arrival times for Vigo's urban buses, Spain.",
+ "credits": "Credits",
+ "github": "Code on GitHub",
+ "developed_by": "Developed by",
+ "data_source_prefix": "Data from",
+ "data_source_middle": "under license",
+ "settings": "Settings",
+ "theme": "Mode:",
+ "theme_light": "Light",
+ "theme_dark": "Dark",
+ "table_style": "Table style:",
+ "table_style_regular": "Show in order",
+ "table_style_grouped": "Group by line",
+ "map_position_mode": "Map position:",
+ "map_position_gps": "GPS position",
+ "map_position_last": "Where I left it",
+ "details_summary": "What does this mean?",
+ "details_table": "The timetable can be shown in two ways:",
+ "details_regular": "Stops are shown in the order they are visited. Apps like Infobus (Vitrasa) use this style.",
+ "details_grouped": "Stops are grouped by bus line. Apps like iTranvias (A Coruña) or Moovit (more or less) use this style."
+ },
+ "stoplist": {
+ "search_placeholder": "Search stop...",
+ "favourites": "Favourites",
+ "recents": "Recent"
+ },
+ "estimates": {
+ "minutes": "min",
+ "meters": "m",
+ "edit": "Edit name",
+ "favourite": "Favourite",
+ "not_found": "Stop not found"
+ },
+ "map": {
+ "popup_title": "Stop",
+ "lines": "Lines"
+ },
+ "common": {
+ "loading": "Loading...",
+ "error": "An unexpected error occurred.",
+ "404": "The requested page could not be found."
+ }
+}
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
new file mode 100644
index 0000000..814019e
--- /dev/null
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -0,0 +1,46 @@
+{
+ "about": {
+ "title": "Sobre UrbanoVigo Web",
+ "description": "Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España.",
+ "credits": "Créditos",
+ "github": "Código en GitHub",
+ "developed_by": "Desarrollado por",
+ "data_source_prefix": "Datos obtenidos de",
+ "data_source_middle": "bajo licencia",
+ "settings": "Ajustes",
+ "theme": "Modo:",
+ "theme_light": "Claro",
+ "theme_dark": "Oscuro",
+ "table_style": "Estilo de tabla:",
+ "table_style_regular": "Mostrar por orden",
+ "table_style_grouped": "Agrupar por línea",
+ "map_position_mode": "Posición del mapa:",
+ "map_position_gps": "Posición GPS",
+ "map_position_last": "Donde lo dejé",
+ "details_summary": "¿Qué significa esto?",
+ "details_table": "La tabla de horarios puede mostrarse de dos formas:",
+ "details_regular": "Las paradas se muestran en el orden en que se visitan. Aplicaciones como Infobus (Vitrasa) usan este estilo.",
+ "details_grouped": "Las paradas se agrupan por la línea de autobús. Aplicaciones como iTranvias (A Coruña) o Moovit (más o menos) usan este estilo."
+ },
+ "stoplist": {
+ "search_placeholder": "Buscar parada...",
+ "favourites": "Favoritas",
+ "recents": "Recientes"
+ },
+ "estimates": {
+ "minutes": "min",
+ "meters": "m",
+ "edit": "Editar nombre",
+ "favourite": "Favorito",
+ "not_found": "No se encontró la parada"
+ },
+ "map": {
+ "popup_title": "Parada",
+ "lines": "Líneas"
+ },
+ "common": {
+ "loading": "Cargando...",
+ "error": "Ha ocurrido un error inesperado.",
+ "404": "La página solicitada no se pudo encontrar."
+ }
+}
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
new file mode 100644
index 0000000..3c0c2fc
--- /dev/null
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -0,0 +1,46 @@
+{
+ "about": {
+ "title": "Sobre UrbanoVigo Web",
+ "description": "Aplicación web para atopar paradas e tempos de chegada dos autobuses urbanos de Vigo, España.",
+ "credits": "Créditos",
+ "github": "Código en GitHub",
+ "developed_by": "Desenvolvido por",
+ "data_source_prefix": "Datos obtidos de",
+ "data_source_middle": "baixo licenza",
+ "settings": "Axustes",
+ "theme": "Modo:",
+ "theme_light": "Claro",
+ "theme_dark": "Escuro",
+ "table_style": "Estilo de táboa:",
+ "table_style_regular": "Mostrar por orde",
+ "table_style_grouped": "Agrupar por liña",
+ "map_position_mode": "Posición do mapa:",
+ "map_position_gps": "Posición GPS",
+ "map_position_last": "Onde o deixei",
+ "details_summary": "Que significa isto?",
+ "details_table": "A táboa de horarios pode mostrarse de dúas formas:",
+ "details_regular": "As paradas móstranse na orde na que se visitan. Aplicacións como Infobus (Vitrasa) usan este estilo.",
+ "details_grouped": "As paradas agrúpanse pola liña de autobús. Aplicacións como iTranvias (A Coruña) ou Moovit (máis ou menos) usan este estilo."
+ },
+ "stoplist": {
+ "search_placeholder": "Buscar parada...",
+ "favourites": "Favoritas",
+ "recents": "Recentes"
+ },
+ "estimates": {
+ "minutes": "min",
+ "meters": "m",
+ "edit": "Editar nome",
+ "favourite": "Favorita",
+ "not_found": "Non se atopou a parada"
+ },
+ "map": {
+ "popup_title": "Parada",
+ "lines": "Liñas"
+ },
+ "common": {
+ "loading": "Cargando...",
+ "error": "Produciuse un erro inesperado.",
+ "404": "Non se puido atopar a páxina solicitada."
+ }
+}
diff --git a/src/frontend/app/root.css b/src/frontend/app/root.css
index 89da67a..a5024df 100644
--- a/src/frontend/app/root.css
+++ b/src/frontend/app/root.css
@@ -45,6 +45,7 @@ body {
.main-content {
flex: 1;
overflow: auto;
+ overflow-x: hidden;
}
.navigation-bar {
diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx
index 9d59ce7..8ffbe86 100644
--- a/src/frontend/app/root.tsx
+++ b/src/frontend/app/root.tsx
@@ -22,6 +22,8 @@ const pmtiles = new Protocol();
maplibregl.addProtocol("pmtiles", pmtiles.tile);
//#endregion
+import "./i18n";
+
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx
index 761a8d4..5dbbc7d 100644
--- a/src/frontend/app/routes/estimates-$id.tsx
+++ b/src/frontend/app/routes/estimates-$id.tsx
@@ -6,6 +6,7 @@ import "./estimates-$id.css";
import { RegularTable } from "../components/RegularTable";
import { useApp } from "../AppContext";
import { GroupedTable } from "../components/GroupedTable";
+import { useTranslation } from "react-i18next";
export interface StopDetails {
stop: {
@@ -32,6 +33,7 @@ const loadData = async (stopId: string) => {
};
export default function Estimates() {
+ const { t } = useTranslation();
const params = useParams();
const stopIdNum = parseInt(params.id ?? "");
const [customName, setCustomName] = useState<string | undefined>(undefined);
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index a938148..ca095e2 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -8,6 +8,7 @@ import { loadStyle } from "app/maps/styleloader";
import type { Feature as GeoJsonFeature, Point } from 'geojson';
import LineIcon from "~/components/LineIcon";
import { Link } from "react-router";
+import { useTranslation } from "react-i18next";
// Default minimal fallback style before dynamic loading
const defaultStyle: StyleSpecification = {
@@ -20,6 +21,7 @@ const defaultStyle: StyleSpecification = {
// Componente principal del mapa
export default function StopMap() {
+ const { t } = useTranslation();
const [stops, setStops] = useState<GeoJsonFeature<Point, { stopId: number; name: string; lines: string[] }>[]>([]);
const [popupInfo, setPopupInfo] = useState<any>(null);
const { mapState, updateMapState, theme } = useApp();
diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx
index b5e91f1..e657c03 100644
--- a/src/frontend/app/routes/settings.tsx
+++ b/src/frontend/app/routes/settings.tsx
@@ -1,64 +1,75 @@
import { useApp } from "../AppContext";
import "./settings.css";
+import { useTranslation } from "react-i18next";
export default function Settings() {
+ const { t, i18n } = useTranslation();
const { theme, setTheme, tableStyle, setTableStyle, mapPositionMode, setMapPositionMode } = useApp();
return (
<div className="page-container">
- <h1 className="page-title">Sobre UrbanoVigo Web</h1>
+ <h1 className="page-title">{t('about.title')}</h1>
<p className="about-description">
- Aplicación web para encontrar paradas y tiempos de llegada de los autobuses
- urbanos de Vigo, España.
+ {t('about.description')}
</p>
<section className="settings-section">
- <h2>Ajustes</h2>
+ <h2>{t('about.settings')}</h2>
<div className="settings-content-inline">
- <label htmlFor="theme" className="form-label-inline">Modo:</label>
- <select id="theme" className="form-select-inline" value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}>
- <option value="light">Claro</option>
- <option value="dark">Oscuro</option>
+ <label htmlFor="theme" className="form-label-inline">{t('about.theme')}</label>
+ <select id="theme" className="form-select-inline" value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}>
+ <option value="light">{t('about.theme_light')}</option>
+ <option value="dark">{t('about.theme_dark')}</option>
</select>
</div>
<div className="settings-content-inline">
- <label htmlFor="tableStyle" className="form-label-inline">Estilo de tabla:</label>
- <select id="tableStyle" className="form-select-inline" value={tableStyle} onChange={(e) => setTableStyle(e.target.value as "regular" | "grouped")}>
- <option value="regular">Mostrar por orden</option>
- <option value="grouped">Agrupar por línea</option>
+ <label htmlFor="tableStyle" className="form-label-inline">{t('about.table_style')}</label>
+ <select id="tableStyle" className="form-select-inline" value={tableStyle} onChange={(e) => setTableStyle(e.target.value as "regular" | "grouped")}>
+ <option value="regular">{t('about.table_style_regular')}</option>
+ <option value="grouped">{t('about.table_style_grouped')}</option>
</select>
</div>
<div className="settings-content-inline">
- <label htmlFor="mapPositionMode" className="form-label-inline">Posición del mapa:</label>
- <select id="mapPositionMode" className="form-select-inline" value={mapPositionMode} onChange={e => setMapPositionMode(e.target.value as 'gps' | 'last')}>
- <option value="gps">Posición GPS</option>
- <option value="last">Donde lo dejé</option>
+ <label htmlFor="mapPositionMode" className="form-label-inline">{t('about.map_position_mode')}</label>
+ <select id="mapPositionMode" className="form-select-inline" value={mapPositionMode} onChange={e => setMapPositionMode(e.target.value as 'gps' | 'last')}>
+ <option value="gps">{t('about.map_position_gps')}</option>
+ <option value="last">{t('about.map_position_last')}</option>
+ </select>
+ </div>
+ <div className="settings-content-inline">
+ <label htmlFor="language" className="form-label-inline">Idioma:</label>
+ <select
+ id="language"
+ className="form-select-inline"
+ value={i18n.language}
+ onChange={e => i18n.changeLanguage(e.target.value)}
+ >
+ <option value="es-ES">Español</option>
+ <option value="gl-ES">Galego</option>
+ <option value="en-GB">English</option>
</select>
</div>
<details className="form-details">
- <summary>¿Qué significa esto?</summary>
- <p>
- La tabla de horarios puede mostrarse de dos formas:
- </p>
+ <summary>{t('about.details_summary')}</summary>
+ <p>{t('about.details_table')}</p>
<dl>
- <dt>Mostrar por orden</dt>
- <dd>Las paradas se muestran en el orden en que se visitan. Aplicaciones como Infobus (Vitrasa) usan este estilo.</dd>
- <dt>Agrupar por línea</dt>
- <dd>Las paradas se agrupan por la línea de autobús. Aplicaciones como iTranvias (A Coruña) o Moovit (más o menos) usan este estilo.</dd>
+ <dt>{t('about.table_style_regular')}</dt>
+ <dd>{t('about.details_regular')}</dd>
+ <dt>{t('about.table_style_grouped')}</dt>
+ <dd>{t('about.details_grouped')}</dd>
</dl>
</details>
</section>
- <h2>Créditos</h2>
+ <h2>{t('about.credits')}</h2>
<p>
<a href="https://github.com/arielcostas/urbanovigo-web" className="about-link" rel="nofollow noreferrer noopener">
- Código en GitHub
+ {t('about.github')}
</a> -
- Desarrollado por <a href="https://www.costas.dev" className="about-link" rel="nofollow noreferrer noopener">
+ {t('about.developed_by')} <a href="https://www.costas.dev" className="about-link" rel="nofollow noreferrer noopener">
Ariel Costas
</a>
</p>
<p>
- Datos obtenidos de <a href="https://datos.vigo.org" className="about-link" rel="nofollow noreferrer noopener">datos.vigo.org</a> bajo
- licencia <a href="https://opendefinition.org/licenses/odc-by/" className="about-link" rel="nofollow noreferrer noopener">Open Data Commons Attribution License</a>
+ {t('about.data_source_prefix')} <a href="https://datos.vigo.org" className="about-link" rel="nofollow noreferrer noopener">datos.vigo.org</a> {t('about.data_source_middle')} <a href="https://opendefinition.org/licenses/odc-by/" className="about-link" rel="nofollow noreferrer noopener">Open Data Commons Attribution License</a>
</p>
</div>
)
diff --git a/src/frontend/app/routes/stoplist.tsx b/src/frontend/app/routes/stoplist.tsx
index ff1da71..0ab6d15 100644
--- a/src/frontend/app/routes/stoplist.tsx
+++ b/src/frontend/app/routes/stoplist.tsx
@@ -3,25 +3,15 @@ import StopDataProvider, { type Stop } from "../data/StopDataProvider";
import StopItem from "../components/StopItem";
import Fuse from "fuse.js";
import './stoplist.css';
-
-const placeholders = [
- "Urzaiz",
- "Gran Vía",
- "Castelao",
- "García Barbón",
- "Valladares",
- "Florida",
- "Pizarro",
- "Estrada Madrid",
- "Sanjurjo Badía"
-];
+import { useTranslation } from "react-i18next";
export default function StopList() {
+ const { t } = useTranslation();
const [data, setData] = useState<Stop[] | null>(null)
const [searchResults, setSearchResults] = useState<Stop[] | null>(null);
const searchTimeout = useRef<NodeJS.Timeout | null>(null);
- const randomPlaceholder = useMemo(() => placeholders[Math.floor(Math.random() * placeholders.length)], []);
+ const randomPlaceholder = useMemo(() => t('stoplist.search_placeholder'), [t]);
const fuse = useMemo(() => new Fuse(data || [], { threshold: 0.3, keys: ['name.original'] }), [data]);
useEffect(() => {