aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/frontend/app/components/ServiceAlerts.css72
-rw-r--r--src/frontend/app/components/ServiceAlerts.tsx22
-rw-r--r--src/frontend/app/components/StopGallery.css161
-rw-r--r--src/frontend/app/components/StopGallery.tsx58
-rw-r--r--src/frontend/app/components/StopGalleryItem.tsx38
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json8
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json8
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json8
-rw-r--r--src/frontend/app/routes/stoplist.tsx99
9 files changed, 422 insertions, 52 deletions
diff --git a/src/frontend/app/components/ServiceAlerts.css b/src/frontend/app/components/ServiceAlerts.css
new file mode 100644
index 0000000..484b45b
--- /dev/null
+++ b/src/frontend/app/components/ServiceAlerts.css
@@ -0,0 +1,72 @@
+/* Service Alerts Container */
+.service-alerts-container {
+ margin-bottom: 1.5rem;
+}
+
+.service-alert {
+ display: flex;
+ gap: 1rem;
+ padding: 1rem;
+ border-radius: 8px;
+ margin-bottom: 0.75rem;
+ border: 1px solid;
+}
+
+.service-alert.info {
+ background-color: #e3f2fd;
+ border-color: #2196f3;
+ color: #0d47a1;
+}
+
+.service-alert.warning {
+ background-color: #fff3e0;
+ border-color: #ff9800;
+ color: #e65100;
+}
+
+.service-alert.error {
+ background-color: #ffebee;
+ border-color: #f44336;
+ color: #b71c1c;
+}
+
+/* Dark mode adjustments */
+@media (prefers-color-scheme: dark) {
+ .service-alert.info {
+ background-color: #0d47a1;
+ border-color: #2196f3;
+ color: #e3f2fd;
+ }
+
+ .service-alert.warning {
+ background-color: #e65100;
+ border-color: #ff9800;
+ color: #fff3e0;
+ }
+
+ .service-alert.error {
+ background-color: #b71c1c;
+ border-color: #f44336;
+ color: #ffebee;
+ }
+}
+
+.alert-icon {
+ font-size: 1.5rem;
+ flex-shrink: 0;
+}
+
+.alert-content {
+ flex: 1;
+}
+
+.alert-title {
+ font-weight: 600;
+ font-size: 1rem;
+ margin-bottom: 0.25rem;
+}
+
+.alert-message {
+ font-size: 0.9rem;
+ line-height: 1.4;
+}
diff --git a/src/frontend/app/components/ServiceAlerts.tsx b/src/frontend/app/components/ServiceAlerts.tsx
new file mode 100644
index 0000000..7966f2a
--- /dev/null
+++ b/src/frontend/app/components/ServiceAlerts.tsx
@@ -0,0 +1,22 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import "./ServiceAlerts.css";
+
+const ServiceAlerts: React.FC = () => {
+ const { t } = useTranslation();
+
+ return (
+ <div className="service-alerts-container">
+ <h2 className="page-subtitle">{t("stoplist.service_alerts")}</h2>
+ <div className="service-alert info">
+ <div className="alert-icon">ℹ️</div>
+ <div className="alert-content">
+ <div className="alert-title">{t("stoplist.alerts_coming_soon")}</div>
+ <div className="alert-message">{t("stoplist.alerts_description")}</div>
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export default ServiceAlerts;
diff --git a/src/frontend/app/components/StopGallery.css b/src/frontend/app/components/StopGallery.css
new file mode 100644
index 0000000..f53f2a5
--- /dev/null
+++ b/src/frontend/app/components/StopGallery.css
@@ -0,0 +1,161 @@
+/* Gallery Container */
+.gallery-container {
+ margin-bottom: 1.5rem;
+}
+
+.gallery-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.75rem;
+}
+
+.gallery-counter {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 2rem;
+ height: 2rem;
+ padding: 0 0.5rem;
+ background-color: var(--button-background-color);
+ color: white;
+ border-radius: 1rem;
+ font-size: 0.9rem;
+ font-weight: 600;
+}
+
+/* Scroll Container */
+.gallery-scroll-container {
+ overflow-x: auto;
+ overflow-y: hidden;
+ -webkit-overflow-scrolling: touch;
+ scroll-snap-type: x mandatory;
+ scrollbar-width: none; /* Firefox */
+ -ms-overflow-style: none; /* IE and Edge */
+ margin: 0 -1rem;
+ padding: 0 1rem;
+}
+
+.gallery-scroll-container::-webkit-scrollbar {
+ display: none; /* Chrome, Safari, Opera */
+}
+
+/* Gallery Track */
+.gallery-track {
+ display: flex;
+ gap: 1rem;
+ padding-bottom: 0.5rem;
+}
+
+/* Gallery Item */
+.gallery-item {
+ flex: 0 0 280px;
+ scroll-snap-align: start;
+ scroll-snap-stop: always;
+}
+
+.gallery-item-link {
+ display: block;
+ padding: 1rem;
+ background-color: var(--card-background-color, var(--message-background-color));
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ text-decoration: none;
+ color: var(--text-color);
+ transition: all 0.2s ease-in-out;
+ height: 100%;
+ min-height: 120px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.gallery-item-link:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
+ border-color: var(--button-background-color);
+}
+
+.gallery-item-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.gallery-item-header .favourite-icon {
+ color: var(--star-color);
+ font-size: 1rem;
+}
+
+.gallery-item-code {
+ font-size: 0.85rem;
+ color: var(--subtitle-color);
+ font-weight: 500;
+}
+
+.gallery-item-name {
+ font-size: 1rem;
+ font-weight: 600;
+ margin-bottom: 0.75rem;
+ line-height: 1.3;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ min-height: 2.6em;
+}
+
+.gallery-item-lines {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ align-items: center;
+}
+
+.more-lines {
+ font-size: 0.75rem;
+ color: var(--subtitle-color);
+ font-weight: 500;
+ padding: 0.125rem 0.375rem;
+ background-color: var(--border-color);
+ border-radius: 4px;
+}
+
+/* Gallery Indicators */
+.gallery-indicators {
+ display: flex;
+ justify-content: center;
+ gap: 0.5rem;
+ margin-top: 1rem;
+ padding: 0.5rem 0;
+}
+
+.gallery-indicator {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background-color: var(--border-color);
+ transition: background-color 0.2s ease-in-out;
+}
+
+.gallery-indicator.active {
+ background-color: var(--button-background-color);
+}
+
+/* Tablet and larger */
+@media (min-width: 768px) {
+ .gallery-item {
+ flex: 0 0 320px;
+ }
+
+ .gallery-scroll-container {
+ margin: 0;
+ padding: 0;
+ }
+}
+
+/* Desktop */
+@media (min-width: 1024px) {
+ .gallery-item {
+ flex: 0 0 340px;
+ }
+}
diff --git a/src/frontend/app/components/StopGallery.tsx b/src/frontend/app/components/StopGallery.tsx
new file mode 100644
index 0000000..7dda637
--- /dev/null
+++ b/src/frontend/app/components/StopGallery.tsx
@@ -0,0 +1,58 @@
+import React, { useRef } from "react";
+import { motion, useMotionValue } from "framer-motion";
+import { type Stop } from "../data/StopDataProvider";
+import StopGalleryItem from "./StopGalleryItem";
+import "./StopGallery.css";
+
+interface StopGalleryProps {
+ stops: Stop[];
+ title: string;
+ emptyMessage?: string;
+}
+
+const StopGallery: React.FC<StopGalleryProps> = ({ stops, title, emptyMessage }) => {
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const x = useMotionValue(0);
+
+ if (stops.length === 0 && emptyMessage) {
+ return (
+ <div className="gallery-container">
+ <h2 className="page-subtitle">{title}</h2>
+ <p className="message">{emptyMessage}</p>
+ </div>
+ );
+ }
+
+ if (stops.length === 0) {
+ return null;
+ }
+
+ return (
+ <div className="gallery-container">
+ <div className="gallery-header">
+ <h2 className="page-subtitle">{title}</h2>
+ <span className="gallery-counter">{stops.length}</span>
+ </div>
+
+ <motion.div
+ className="gallery-scroll-container"
+ ref={scrollRef}
+ style={{ x }}
+ >
+ <div className="gallery-track">
+ {stops.map((stop) => (
+ <StopGalleryItem key={stop.stopId} stop={stop} />
+ ))}
+ </div>
+ </motion.div>
+
+ <div className="gallery-indicators">
+ {stops.map((_, index) => (
+ <div key={index} className="gallery-indicator" />
+ ))}
+ </div>
+ </div>
+ );
+};
+
+export default StopGallery;
diff --git a/src/frontend/app/components/StopGalleryItem.tsx b/src/frontend/app/components/StopGalleryItem.tsx
new file mode 100644
index 0000000..24d92a2
--- /dev/null
+++ b/src/frontend/app/components/StopGalleryItem.tsx
@@ -0,0 +1,38 @@
+import React from "react";
+import { Link } from "react-router";
+import { type Stop } from "../data/StopDataProvider";
+import LineIcon from "./LineIcon";
+import { useApp } from "../AppContext";
+import StopDataProvider from "../data/StopDataProvider";
+
+interface StopGalleryItemProps {
+ stop: Stop;
+}
+
+const StopGalleryItem: React.FC<StopGalleryItemProps> = ({ stop }) => {
+ const { region } = useApp();
+
+ return (
+ <div className="gallery-item">
+ <Link className="gallery-item-link" to={`/estimates/${stop.stopId}`}>
+ <div className="gallery-item-header">
+ {stop.favourite && <span className="favourite-icon">★</span>}
+ <span className="gallery-item-code">({stop.stopId})</span>
+ </div>
+ <div className="gallery-item-name">
+ {StopDataProvider.getDisplayName(region, stop)}
+ </div>
+ <div className="gallery-item-lines">
+ {stop.lines?.slice(0, 3).map((line) => (
+ <LineIcon key={line} line={line} region={region} />
+ ))}
+ {stop.lines && stop.lines.length > 3 && (
+ <span className="more-lines">+{stop.lines.length - 3}</span>
+ )}
+ </div>
+ </Link>
+ </div>
+ );
+};
+
+export default StopGalleryItem;
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index 992a311..d5dfed0 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -40,13 +40,17 @@
"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...",
+ "search_placeholder": "Search stop by name or code...",
"search_label": "Search stops",
"search_results": "Search results",
"favourites": "Favourite stops",
"no_favourites": "Go to a stop and mark it as favourite to see it here.",
"recents": "Recent",
- "all_stops": "Stops"
+ "all_stops": "Stops",
+ "nearby_stops": "Nearby stops",
+ "service_alerts": "Service alerts",
+ "alerts_coming_soon": "Feature coming soon",
+ "alerts_description": "Service alerts and disruption notifications will be available here soon."
},
"estimates": {
"minutes": "min",
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index 1df399d..357da23 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -40,13 +40,17 @@
"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...",
+ "search_placeholder": "Buscar parada por nombre o código...",
"search_label": "Buscar paradas",
"search_results": "Resultados de la búsqueda",
"favourites": "Paradas favoritas",
"no_favourites": "Accede a una parada y márcala como favorita para verla aquí.",
"recents": "Recientes",
- "all_stops": "Paradas"
+ "all_stops": "Paradas",
+ "nearby_stops": "Paradas cercanas",
+ "service_alerts": "Alertas del servicio",
+ "alerts_coming_soon": "Función próximamente",
+ "alerts_description": "Las alertas del servicio y notificaciones de interrupciones estarán disponibles aquí pronto."
},
"estimates": {
"minutes": "min",
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
index 5a04aff..9dab87a 100644
--- a/src/frontend/app/i18n/locales/gl-ES.json
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -40,13 +40,17 @@
"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...",
+ "search_placeholder": "Buscar parada por nome ou código...",
"search_label": "Buscar paradas",
"search_results": "Resultados da busca",
"favourites": "Paradas favoritas",
"no_favourites": "Accede a unha parada e márcaa como favorita para vela aquí.",
"recents": "Recentes",
- "all_stops": "Paradas"
+ "all_stops": "Paradas",
+ "nearby_stops": "Paradas cercanas",
+ "service_alerts": "Alertas do servizo",
+ "alerts_coming_soon": "Función proximamente",
+ "alerts_description": "As alertas do servizo e notificacións de interrupcións estarán dispoñibles aquí pronto."
},
"estimates": {
"minutes": "min",
diff --git a/src/frontend/app/routes/stoplist.tsx b/src/frontend/app/routes/stoplist.tsx
index e77dfb8..80267ea 100644
--- a/src/frontend/app/routes/stoplist.tsx
+++ b/src/frontend/app/routes/stoplist.tsx
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import StopDataProvider, { type Stop } from "../data/StopDataProvider";
import StopItem from "../components/StopItem";
import StopItemSkeleton from "../components/StopItemSkeleton";
+import StopGallery from "../components/StopGallery";
+import ServiceAlerts from "../components/ServiceAlerts";
import Fuse from "fuse.js";
import "./stoplist.css";
import { useTranslation } from "react-i18next";
@@ -30,7 +32,10 @@ export default function StopList() {
);
const fuse = useMemo(
- () => new Fuse(data || [], { threshold: 0.3, keys: ["name.original"] }),
+ () => new Fuse(data || [], {
+ threshold: 0.3,
+ keys: ["name.original", "name.intersect", "stopId"]
+ }),
[data],
);
@@ -188,14 +193,14 @@ export default function StopList() {
}, [loadStops]);
const handleStopSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
- const stopName = event.target.value || "";
+ const searchQuery = event.target.value || "";
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
}
searchTimeout.current = setTimeout(() => {
- if (stopName.length === 0) {
+ if (searchQuery.length === 0) {
setSearchResults(null);
return;
}
@@ -205,8 +210,27 @@ export default function StopList() {
return;
}
- const results = fuse.search(stopName);
- const items = results.map((result) => result.item);
+ // Check if search query is a number (stop code search)
+ const isNumericSearch = /^\d+$/.test(searchQuery.trim());
+
+ let items: Stop[];
+ if (isNumericSearch) {
+ // Direct match for stop codes
+ const stopId = parseInt(searchQuery.trim(), 10);
+ const exactMatch = data.filter(stop => stop.stopId === stopId);
+ if (exactMatch.length > 0) {
+ items = exactMatch;
+ } else {
+ // Fuzzy search if no exact match
+ const results = fuse.search(searchQuery);
+ items = results.map((result) => result.item);
+ }
+ } else {
+ // Text search using Fuse.js
+ const results = fuse.search(searchQuery);
+ items = results.map((result) => result.item);
+ }
+
setSearchResults(items);
}, 300);
};
@@ -243,60 +267,43 @@ export default function StopList() {
</div>
)}
- <div className="list-container">
- <h2 className="page-subtitle">{t("stoplist.favourites")}</h2>
-
- {favouriteIds.length === 0 && (
- <p className="message">
- {t(
- "stoplist.no_favourites",
- "Accede a una parada y márcala como favorita para verla aquí.",
- )}
- </p>
- )}
-
- <ul className="list">
- {loading && favouriteIds.length > 0 &&
- favouriteIds.map((id) => (
- <StopItemSkeleton key={id} showId={true} stopId={id} />
- ))
- }
- {!loading && favouriteStops
- .sort((a, b) => a.stopId - b.stopId)
- .map((stop) => <StopItem key={stop.stopId} stop={stop} />)}
- </ul>
- </div>
-
- {(recentIds.length > 0 || (!loading && recentStops.length > 0)) && (
- <div className="list-container">
- <h2 className="page-subtitle">{t("stoplist.recents")}</h2>
+ {!loading && (
+ <StopGallery
+ stops={recentStops.slice(0, 5)}
+ title={t("stoplist.recents")}
+ />
+ )}
- <ul className="list">
- {loading && recentIds.length > 0 &&
- recentIds.map((id) => (
- <StopItemSkeleton key={id} showId={true} stopId={id} />
- ))
- }
- {!loading && recentStops.map((stop) => (
- <StopItem key={stop.stopId} stop={stop} />
- ))}
- </ul>
- </div>
+ {!loading && (
+ <StopGallery
+ stops={favouriteStops.sort((a, b) => a.stopId - b.stopId)}
+ title={t("stoplist.favourites")}
+ emptyMessage={t("stoplist.no_favourites")}
+ />
)}
+ <ServiceAlerts />
+
<div className="list-container">
- <h2 className="page-subtitle">{t("stoplist.all_stops", "Paradas")}</h2>
+ <h2 className="page-subtitle">
+ {userLocation
+ ? t("stoplist.nearby_stops", "Nearby stops")
+ : t("stoplist.all_stops", "Paradas")
+ }
+ </h2>
<ul className="list">
{loading && (
<>
- {Array.from({ length: 8 }, (_, index) => (
+ {Array.from({ length: 6 }, (_, index) => (
<StopItemSkeleton key={`skeleton-${index}`} />
))}
</>
)}
{!loading && data
- ? sortedAllStops.map((stop) => <StopItem key={stop.stopId} stop={stop} />)
+ ? (userLocation ? sortedAllStops.slice(0, 6) : sortedAllStops).map((stop) => (
+ <StopItem key={stop.stopId} stop={stop} />
+ ))
: null}
</ul>
</div>