aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app/components')
-rw-r--r--src/frontend/app/components/arrivals/ArrivalCard.tsx43
-rw-r--r--src/frontend/app/components/arrivals/ArrivalList.tsx11
-rw-r--r--src/frontend/app/components/journey/ActiveJourneyBanner.tsx136
-rw-r--r--src/frontend/app/components/layout/AppShell.tsx6
4 files changed, 193 insertions, 3 deletions
diff --git a/src/frontend/app/components/arrivals/ArrivalCard.tsx b/src/frontend/app/components/arrivals/ArrivalCard.tsx
index bdd20a5..9c68a97 100644
--- a/src/frontend/app/components/arrivals/ArrivalCard.tsx
+++ b/src/frontend/app/components/arrivals/ArrivalCard.tsx
@@ -1,4 +1,4 @@
-import { AlertTriangle, BusFront, LocateIcon } from "lucide-react";
+import { AlertTriangle, BusFront, LocateIcon, Navigation } from "lucide-react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import Marquee from "react-fast-marquee";
import { useTranslation } from "react-i18next";
@@ -9,6 +9,8 @@ import "./ArrivalCard.css";
interface ArrivalCardProps {
arrival: Arrival;
onClick?: () => void;
+ onTrack?: () => void;
+ isTracked?: boolean;
}
const AutoMarquee = ({ text }: { text: string }) => {
@@ -57,6 +59,8 @@ const AutoMarquee = ({ text }: { text: string }) => {
export const ArrivalCard: React.FC<ArrivalCardProps> = ({
arrival,
onClick,
+ onTrack,
+ isTracked = false,
}) => {
const { t } = useTranslation();
const {
@@ -287,6 +291,43 @@ export const ArrivalCard: React.FC<ArrivalCardProps> = ({
</span>
);
})}
+
+ {onTrack && estimate.precision !== "past" && (
+ // Use a <span> instead of a <button> here because this element can
+ // be rendered inside a <button> (when isClickable=true), and nested
+ // <button> elements are invalid HTML.
+ <span
+ role="button"
+ tabIndex={0}
+ onClick={(e) => {
+ e.stopPropagation();
+ onTrack();
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ e.stopPropagation();
+ onTrack();
+ }
+ }}
+ aria-label={
+ isTracked
+ ? t("journey.stop_tracking", "Detener seguimiento")
+ : t("journey.track_bus", "Seguir este autobús")
+ }
+ aria-pressed={isTracked}
+ className={`ml-auto text-xs px-2.5 py-0.5 rounded-full flex items-center gap-1 shrink-0 font-medium tracking-wide transition-colors cursor-pointer select-none ${
+ isTracked
+ ? "bg-blue-600 text-white hover:bg-blue-700"
+ : "bg-black/[0.04] dark:bg-white/[0.08] text-slate-500 dark:text-slate-400 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:text-blue-600 dark:hover:text-blue-400"
+ }`}
+ >
+ <Navigation className="w-3 h-3" />
+ {isTracked
+ ? t("journey.tracking", "Siguiendo")
+ : t("journey.track", "Seguir")}
+ </span>
+ )}
</div>
</Tag>
);
diff --git a/src/frontend/app/components/arrivals/ArrivalList.tsx b/src/frontend/app/components/arrivals/ArrivalList.tsx
index 83eb4f0..18885c8 100644
--- a/src/frontend/app/components/arrivals/ArrivalList.tsx
+++ b/src/frontend/app/components/arrivals/ArrivalList.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { useTranslation } from "react-i18next";
import { type Arrival } from "../../api/schema";
import { ArrivalCard } from "./ArrivalCard";
import { ReducedArrivalCard } from "./ReducedArrivalCard";
@@ -7,21 +8,25 @@ interface ArrivalListProps {
arrivals: Arrival[];
reduced?: boolean;
onArrivalClick?: (arrival: Arrival) => void;
+ onTrackArrival?: (arrival: Arrival) => void;
+ trackedTripId?: string;
}
export const ArrivalList: React.FC<ArrivalListProps> = ({
arrivals,
reduced,
onArrivalClick,
+ onTrackArrival,
+ trackedTripId,
}) => {
+ const { t } = useTranslation();
const clickable = Boolean(onArrivalClick);
return (
<div className="flex flex-col flex-1 gap-3">
{arrivals.length === 0 && (
<div className="text-center text-muted mt-16">
- {/* TOOD i18n */}
- No hay llegadas próximas disponibles para esta parada.
+ {t("estimates.none", "No hay llegadas próximas disponibles para esta parada.")}
</div>
)}
{arrivals.map((arrival, index) =>
@@ -36,6 +41,8 @@ export const ArrivalList: React.FC<ArrivalListProps> = ({
key={`${arrival.tripId}-${index}`}
arrival={arrival}
onClick={clickable ? () => onArrivalClick?.(arrival) : undefined}
+ onTrack={onTrackArrival ? () => onTrackArrival(arrival) : undefined}
+ isTracked={trackedTripId === arrival.tripId}
/>
)
)}
diff --git a/src/frontend/app/components/journey/ActiveJourneyBanner.tsx b/src/frontend/app/components/journey/ActiveJourneyBanner.tsx
new file mode 100644
index 0000000..df853ca
--- /dev/null
+++ b/src/frontend/app/components/journey/ActiveJourneyBanner.tsx
@@ -0,0 +1,136 @@
+import { Bell, Map, X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+import RouteIcon from "~/components/RouteIcon";
+import { useJourney } from "~/contexts/JourneyContext";
+import { useStopArrivals } from "~/hooks/useArrivals";
+
+/**
+ * A sticky banner rendered at the bottom of the AppShell (above the nav bar)
+ * while a journey is being tracked. Shows live minutes-remaining count and
+ * lets the user cancel tracking.
+ */
+export function ActiveJourneyBanner() {
+ const { t } = useTranslation();
+ const { activeJourney, stopJourney } = useJourney();
+
+ const { data } = useStopArrivals(
+ activeJourney?.stopId ?? "",
+ false,
+ !!activeJourney
+ );
+
+ const [permissionState, setPermissionState] = useState<
+ NotificationPermission | "unsupported"
+ >(() => {
+ if (typeof Notification === "undefined") return "unsupported";
+ return Notification.permission;
+ });
+
+ // Request notification permission the first time a journey starts
+ const hasRequestedRef = useRef(false);
+ useEffect(() => {
+ if (!activeJourney || hasRequestedRef.current) return;
+ if (
+ typeof Notification === "undefined" ||
+ Notification.permission !== "default"
+ )
+ return;
+
+ hasRequestedRef.current = true;
+ Notification.requestPermission().then((perm) => {
+ setPermissionState(perm);
+ });
+ }, [activeJourney]);
+
+ if (!activeJourney) return null;
+
+ const liveArrival = data?.arrivals.find(
+ (a) => a.tripId === activeJourney.tripId
+ );
+
+ const minutes = liveArrival?.estimate.minutes;
+ const precision = liveArrival?.estimate.precision;
+
+ const minutesLabel =
+ minutes == null
+ ? "–"
+ : minutes <= 0
+ ? t("journey.arriving_now", "¡Llegando!")
+ : t("journey.minutes_away", {
+ defaultValue: "{{minutes}} min",
+ minutes,
+ });
+
+ const isApproaching =
+ minutes != null &&
+ minutes <= activeJourney.notifyAtMinutes &&
+ precision !== "past";
+
+ return (
+ <div
+ role="status"
+ aria-live="polite"
+ className={`relative mx-3 mb-2 rounded-2xl shadow-lg overflow-hidden border transition-colors ${
+ isApproaching
+ ? "bg-primary-400 border-primary-500"
+ : "bg-primary-600 border-primary-700"
+ }`}
+ >
+ {/* Clickable body — navigates to the stop and opens the map for the tracked trip */}
+ <Link
+ to={`/stops/${activeJourney.stopId}`}
+ state={{
+ openMap: true,
+ selectedTripId: activeJourney.tripId,
+ }}
+ aria-label={t("journey.view_on_map", "View on map")}
+ className="flex items-center gap-3 px-4 py-2.5 pr-14 text-sm text-white w-full"
+ >
+ <RouteIcon
+ line={activeJourney.routeShortName}
+ colour={activeJourney.routeColour}
+ textColour={activeJourney.routeTextColour}
+ mode="pill"
+ />
+
+ <div className="flex-1 min-w-0">
+ <p className="font-semibold leading-tight truncate">
+ {activeJourney.headsignDestination ??
+ t("journey.tracking_bus", "Siguiendo autobús")}
+ </p>
+ <p className="text-xs opacity-80 truncate">
+ {activeJourney.stopName}
+ {" · "}
+ {minutesLabel}
+ </p>
+ </div>
+
+ {permissionState === "denied" && (
+ <span
+ title={t(
+ "journey.notifications_blocked",
+ "Notificaciones bloqueadas"
+ )}
+ className="opacity-60 shrink-0"
+ >
+ <Bell size={16} className="line-through" />
+ </span>
+ )}
+
+ <Map size={16} className="opacity-60 shrink-0" />
+ </Link>
+
+ {/* Cancel button — absolutely positioned so it doesn't nest inside the Link */}
+ <button
+ type="button"
+ onClick={stopJourney}
+ aria-label={t("journey.stop_tracking", "Detener seguimiento")}
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-full text-white hover:bg-white/20 transition-colors shrink-0"
+ >
+ <X size={18} />
+ </button>
+ </div>
+ );
+}
diff --git a/src/frontend/app/components/layout/AppShell.tsx b/src/frontend/app/components/layout/AppShell.tsx
index 50f5742..6bb5e34 100644
--- a/src/frontend/app/components/layout/AppShell.tsx
+++ b/src/frontend/app/components/layout/AppShell.tsx
@@ -4,6 +4,8 @@ import {
PageTitleProvider,
usePageTitleContext,
} from "~/contexts/PageTitleContext";
+import { ActiveJourneyBanner } from "~/components/journey/ActiveJourneyBanner";
+import { useJourneyTracker } from "~/hooks/useJourneyTracker";
import { ThemeColorManager } from "../ThemeColorManager";
import "./AppShell.css";
import { Drawer } from "./Drawer";
@@ -15,6 +17,9 @@ const AppShellContent: React.FC = () => {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const location = useLocation();
+ // Mount journey tracker at shell level so tracking persists across navigation
+ useJourneyTracker();
+
return (
<div className="app-shell">
<ThemeColorManager />
@@ -29,6 +34,7 @@ const AppShellContent: React.FC = () => {
<Outlet key={location.pathname} />
</main>
</div>
+ <ActiveJourneyBanner />
<footer className="app-shell__bottom-nav">
<NavBar />
</footer>