Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"prepare": "husky"
},
"dependencies": {
"@mapbox/mapbox-gl-language": "^1.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
Expand Down
9 changes: 9 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,12 @@
/* Layout */
--sidebar-width: 551px;
}

/* Mapbox: Ensure popup always appears above markers */
.mapboxgl-popup {
z-index: 100 !important;
}

.mapboxgl-marker {
z-index: 1 !important;
}
18 changes: 15 additions & 3 deletions src/app/network/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import { cache } from 'react';
import { notFound } from 'next/navigation';
import { fetchNetworkDetail } from '@/lib/api/network-detail';
import { NetworkDetailClient } from './network-detail-client';
import { GeolocationProvider } from '@/contexts';

interface NetworkDetailPageProps {
params: Promise<{ id: string }>;
}

// Memoize fetchNetworkDetail to avoid duplicate API calls
// between generateMetadata and the page component
const getNetworkDetail = cache(async (id: string) => {
return fetchNetworkDetail(id);
});

export async function generateMetadata({ params }: NetworkDetailPageProps) {
const { id } = await params;

try {
const network = await fetchNetworkDetail(id);
const network = await getNetworkDetail(id);
return {
title: `${network.name} | CycleMap`,
description: `View stations for ${network.name} in ${network.location.city}, ${network.location.country}`,
Expand All @@ -24,11 +32,15 @@ export async function generateMetadata({ params }: NetworkDetailPageProps) {

export default async function NetworkDetailPage({ params }: NetworkDetailPageProps) {
const { id } = await params;
const network = await fetchNetworkDetail(id);
const network = await getNetworkDetail(id);

if (!network) {
notFound();
}

return <NetworkDetailClient network={network} />;
return (
<GeolocationProvider>
<NetworkDetailClient network={network} />
</GeolocationProvider>
);
}
32 changes: 17 additions & 15 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Sidebar } from '@/components/layout/sidebar';
import { MapContainer } from '@/components/map';
import { NetworkSidebar } from '@/components/networks';
import { SidebarOpenButton } from '@/components/ui/sidebar-open-button';
import { FilteredNetworksProvider, SidebarProvider } from '@/contexts';
import { FilteredNetworksProvider, SidebarProvider, GeolocationProvider } from '@/contexts';
import { fetchNetworks } from '@/lib/api/networks';
import { getUniqueCountries } from '@/lib/utils';

Expand All @@ -11,20 +11,22 @@ export default async function Home() {
const countries = getUniqueCountries(networks);

return (
<FilteredNetworksProvider networks={networks}>
<SidebarProvider>
<div className="h-screen lg:flex lg:flex-row">
<Sidebar>
<NetworkSidebar countries={countries} />
</Sidebar>
<GeolocationProvider>
<FilteredNetworksProvider networks={networks}>
<SidebarProvider>
<div className="h-screen lg:flex lg:flex-row">
<Sidebar>
<NetworkSidebar countries={countries} />
</Sidebar>

<main className="absolute inset-0 lg:relative lg:flex-1">
{/* Botón hamburguesa - solo visible en mobile */}
<SidebarOpenButton className="absolute left-4 top-4 z-20" variant="light" />
<MapContainer />
</main>
</div>
</SidebarProvider>
</FilteredNetworksProvider>
<main className="absolute inset-0 lg:relative lg:flex-1">
{/* Botón hamburguesa - solo visible en mobile */}
<SidebarOpenButton className="absolute left-4 top-4 z-20" variant="light" />
<MapContainer />
</main>
</div>
</SidebarProvider>
</FilteredNetworksProvider>
</GeolocationProvider>
);
}
46 changes: 28 additions & 18 deletions src/components/map/__tests__/near-me-button.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { NearMeButton } from '../../ui/near-me-button';
import { useGeolocationContext } from '@/contexts';
import { GEOLOCATION_CONFIG } from '@/lib/constants';

vi.mock('@/hooks/use-geolocation', () => ({
useGeolocation: vi.fn(),
}));

import { useGeolocation } from '@/hooks/use-geolocation';
// Mock the useGeolocationContext hook
vi.mock('@/contexts', async () => {
const actual = await vi.importActual('@/contexts');
return {
...actual,
useGeolocationContext: vi.fn(),
};
});

const mockUseGeolocation = useGeolocation as ReturnType<typeof vi.fn>;
const mockUseGeolocationContext = useGeolocationContext as ReturnType<typeof vi.fn>;

const createMockMapRef = () => ({
current: {
Expand All @@ -24,7 +28,7 @@ describe('NearMeButton', () => {

describe('default state', () => {
it('should render location button', () => {
mockUseGeolocation.mockReturnValue({
mockUseGeolocationContext.mockReturnValue({
position: null,
isLoading: false,
error: null,
Expand All @@ -41,7 +45,7 @@ describe('NearMeButton', () => {

it('should call requestLocation when clicked', () => {
const requestLocation = vi.fn();
mockUseGeolocation.mockReturnValue({
mockUseGeolocationContext.mockReturnValue({
position: null,
isLoading: false,
error: null,
Expand All @@ -60,7 +64,7 @@ describe('NearMeButton', () => {

describe('loading state', () => {
it('should show loading spinner when isLoading is true', () => {
mockUseGeolocation.mockReturnValue({
mockUseGeolocationContext.mockReturnValue({
position: null,
isLoading: true,
error: null,
Expand All @@ -78,7 +82,7 @@ describe('NearMeButton', () => {

describe('error state', () => {
it('should show error state when there is an error', () => {
mockUseGeolocation.mockReturnValue({
mockUseGeolocationContext.mockReturnValue({
position: null,
isLoading: false,
error: 'PERMISSION_DENIED',
Expand All @@ -96,7 +100,7 @@ describe('NearMeButton', () => {
it('should clear error and retry when error button is clicked', () => {
const clearError = vi.fn();
const requestLocation = vi.fn();
mockUseGeolocation.mockReturnValue({
mockUseGeolocationContext.mockReturnValue({
position: null,
isLoading: false,
error: 'PERMISSION_DENIED',
Expand All @@ -119,7 +123,7 @@ describe('NearMeButton', () => {
const mapRef = createMockMapRef();
const position = { latitude: 40.4168, longitude: -3.7038 };

mockUseGeolocation.mockReturnValue({
mockUseGeolocationContext.mockReturnValue({
position,
isLoading: false,
error: null,
Expand All @@ -130,6 +134,9 @@ describe('NearMeButton', () => {

render(<NearMeButton mapRef={mapRef as never} />);

// Click the button to trigger flyTo
fireEvent.click(screen.getByRole('button'));

await waitFor(() => {
expect(mapRef.current.flyTo).toHaveBeenCalledWith({
center: [-3.7038, 40.4168],
Expand All @@ -144,7 +151,7 @@ describe('NearMeButton', () => {
const mapRef = createMockMapRef();
const position = { latitude: 40.4168, longitude: -3.7038 };

mockUseGeolocation.mockReturnValue({
mockUseGeolocationContext.mockReturnValue({
position,
isLoading: false,
error: null,
Expand All @@ -155,6 +162,9 @@ describe('NearMeButton', () => {

render(<NearMeButton mapRef={mapRef as never} zoom={GEOLOCATION_CONFIG.STATION_ZOOM} />);

// Click the button to trigger flyTo
fireEvent.click(screen.getByRole('button'));

await waitFor(() => {
expect(mapRef.current.flyTo).toHaveBeenCalledWith({
center: [-3.7038, 40.4168],
Expand All @@ -168,7 +178,7 @@ describe('NearMeButton', () => {
it('should not call flyTo when position is null', () => {
const mapRef = createMockMapRef();

mockUseGeolocation.mockReturnValue({
mockUseGeolocationContext.mockReturnValue({
position: null,
isLoading: false,
error: null,
Expand All @@ -186,7 +196,7 @@ describe('NearMeButton', () => {
const mapRef = { current: null };
const position = { latitude: 40.4168, longitude: -3.7038 };

mockUseGeolocation.mockReturnValue({
mockUseGeolocationContext.mockReturnValue({
position,
isLoading: false,
error: null,
Expand All @@ -202,7 +212,7 @@ describe('NearMeButton', () => {

describe('accessibility', () => {
it('should have accessible button in default state', () => {
mockUseGeolocation.mockReturnValue({
mockUseGeolocationContext.mockReturnValue({
position: null,
isLoading: false,
error: null,
Expand All @@ -218,7 +228,7 @@ describe('NearMeButton', () => {
});

it('should have title attribute with error message in error state', () => {
mockUseGeolocation.mockReturnValue({
mockUseGeolocationContext.mockReturnValue({
position: null,
isLoading: false,
error: 'PERMISSION_DENIED',
Expand All @@ -236,7 +246,7 @@ describe('NearMeButton', () => {

describe('className prop', () => {
it('should apply custom className', () => {
mockUseGeolocation.mockReturnValue({
mockUseGeolocationContext.mockReturnValue({
position: null,
isLoading: false,
error: null,
Expand Down
30 changes: 29 additions & 1 deletion src/components/map/mapbox-map.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
'use client';

import { useRef, useCallback, useState } from 'react';
import { useRef, useCallback, useState, useEffect } from 'react';
import Map, { NavigationControl } from 'react-map-gl/mapbox';
import type { MapRef, ErrorEvent, MapMouseEvent } from 'react-map-gl/mapbox';
import type { GeoJSONSource } from 'mapbox-gl';
import { useRouter } from 'next/navigation';
import { ClusterMarkers } from './cluster-markers';
import { MapError } from './map-error';
import { useFitBounds } from '@/hooks/use-fit-bounds';
import { useMapLanguage } from '@/hooks/use-map-language';
import { useGeolocationContext } from '@/contexts';
import { MAP_CONFIG, MAPBOX_CONFIG, CLUSTER_CONFIG, GEOLOCATION_CONFIG } from '@/lib/constants';
import type { Network } from '@/types';

Expand All @@ -25,12 +27,31 @@ const INTERACTIVE_LAYER_IDS = [...CLUSTER_CONFIG.LAYER_IDS.CLUSTERS];

export function MapboxMap({ networks }: MapboxMapProps) {
const mapRef = useRef<MapRef>(null);
const [mapInstance, setMapInstance] = useState<MapRef | null>(null);
const router = useRouter();
const [mapError, setMapError] = useState<string | null>(null);
const { position: userPosition } = useGeolocationContext();
const hasAutoFlyRef = useRef(false);

// Auto-fit bounds when networks change
useFitBounds(mapRef, networks);

// Set map labels to English
useMapLanguage(mapInstance);

// Auto-fly to user location on first position detection
useEffect(() => {
if (!mapInstance || !userPosition || hasAutoFlyRef.current) return;

hasAutoFlyRef.current = true;
mapInstance.flyTo({
center: [userPosition.longitude, userPosition.latitude],
zoom: GEOLOCATION_CONFIG.NETWORK_ZOOM,
duration: MAP_CONFIG.ANIMATION_DURATION,
essential: true,
});
}, [mapInstance, userPosition]);

const handleMapError = useCallback((e: ErrorEvent) => {
const errorMessage = e.error?.message || '';
if (errorMessage.includes('access token')) {
Expand Down Expand Up @@ -93,6 +114,12 @@ export function MapboxMap({ networks }: MapboxMapProps) {
}
}, []);

const handleMapLoad = useCallback(() => {
if (mapRef.current) {
setMapInstance(mapRef.current);
}
}, []);

// Validate token before rendering
if (!MAPBOX_TOKEN) {
return (
Expand Down Expand Up @@ -122,6 +149,7 @@ export function MapboxMap({ networks }: MapboxMapProps) {
mapStyle={MAPBOX_CONFIG.STYLE}
projection="mercator"
style={{ width: '100%', height: '100%' }}
onLoad={handleMapLoad}
onError={handleMapError}
onClick={handleMapClick}
onMouseEnter={handleMouseEnter}
Expand Down
Loading