diff options
Diffstat (limited to 'src/frontend/app/components')
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> |
