From 3899b9b34bbd38fefa39208da0c36efb2a409eff Mon Sep 17 00:00:00 2001 From: stephen leong Date: Sun, 19 Apr 2026 15:54:24 +0100 Subject: [PATCH 01/12] feat: restructure Neighbourhoods component and update import path for better organization --- frontend/src/App.tsx | 2 +- frontend/src/pages/public/{ => explore}/Neighbourhoods.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename frontend/src/pages/public/{ => explore}/Neighbourhoods.tsx (92%) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0e324eb..6f5987b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,7 @@ import "./App.css"; import Home from "./pages/public/Home"; import Events from "./pages/public/Events"; -import Neighbourhoods from "./pages/public/Neighbourhoods"; +import Neighbourhoods from "./pages/public/explore/Neighbourhoods"; import PreTravelChecklist from "./pages/public/travel-advice/PreTravelChecklist"; import Auth from "./pages/public/Auth"; diff --git a/frontend/src/pages/public/Neighbourhoods.tsx b/frontend/src/pages/public/explore/Neighbourhoods.tsx similarity index 92% rename from frontend/src/pages/public/Neighbourhoods.tsx rename to frontend/src/pages/public/explore/Neighbourhoods.tsx index c642a58..6f26252 100644 --- a/frontend/src/pages/public/Neighbourhoods.tsx +++ b/frontend/src/pages/public/explore/Neighbourhoods.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; -import CONFIG from "../../config"; +import CONFIG from "../../../config"; -import HoverCard from "../../components/globals/HoverCard"; -import Layout from "../layout"; +import HoverCard from "../../../components/globals/HoverCard"; +import Layout from "../../layout"; import { useTranslation } from "react-i18next"; interface Neighbourhood { From f053a073c586008e8d95f0d8fa0ddb1af1589361 Mon Sep 17 00:00:00 2001 From: stephen leong Date: Sun, 19 Apr 2026 16:49:11 +0100 Subject: [PATCH 02/12] feat: add TopAttractions page and restructure routes for explore section --- frontend/src/App.tsx | 7 +- frontend/src/components/maps/Map.tsx | 83 +++++++++++++++---- frontend/src/components/maps/Markers.tsx | 11 ++- frontend/src/components/navigation/Navbar.tsx | 12 +-- frontend/src/components/navigation/menu.ts | 4 +- .../pages/public/explore/TopAttractions.tsx | 47 +++++++++++ 6 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 frontend/src/pages/public/explore/TopAttractions.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6f5987b..6820966 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { resolvePathLocaleSegment, } from "./i18n/locales"; import { localizePath, parseLocaleFromPathname } from "./utils/localeRouting"; +import TopAttractions from "./pages/public/explore/TopAttractions"; const KNOWN_UNLOCALIZED_PATH_PATTERNS = [ /^\/$/, @@ -157,7 +158,11 @@ function App() { } /> } /> } /> - } /> + + + } /> + } /> + } /> diff --git a/frontend/src/components/maps/Map.tsx b/frontend/src/components/maps/Map.tsx index 24edc2e..2b662bf 100644 --- a/frontend/src/components/maps/Map.tsx +++ b/frontend/src/components/maps/Map.tsx @@ -1,37 +1,90 @@ import { MapContainer, TileLayer } from "react-leaflet"; -import {HomeMarker, SingaporeMarker} from "./Markers"; +import { AttractionMarker, HomeMarker, SingaporeMarker } from "./Markers"; import { FitBounds } from "./utils"; type PositionType = [number, number]; +type BasemapType = "openstreetmap" | "onemap"; + +type MapPoint = { + position: PositionType; + title: string; + description?: string; +}; + +const SINGAPORE_CENTER: PositionType = [1.364917, 103.822872]; +const SINGAPORE_BOUNDS: [PositionType, PositionType] = [ + [1.144, 103.535], + [1.494, 104.502], +]; + +const ONE_MAP_ATTRIBUTION = + ' OneMap © contributors | Singapore Land Authority'; + +const OSM_ATTRIBUTION = + '© OpenStreetMap contributors'; + interface MapProps { homePosition?: PositionType; centerPosition?: PositionType; + points?: MapPoint[]; + basemap?: BasemapType; } -const Map = ({ homePosition, centerPosition = [1.364917 ,103.822872]}: MapProps) => { - console.log(homePosition) +const Map = ({ + homePosition, + centerPosition = SINGAPORE_CENTER, + points, + basemap = "openstreetmap", +}: MapProps) => { + const mapPoints = points ?? []; + const hasPoints = mapPoints.length > 0; + const mapCenter = mapPoints[0]?.position ?? homePosition ?? centerPosition; + const isOneMap = basemap === "onemap"; + return ( - {homePosition ? ( + {hasPoints ? ( + <> + {mapPoints.map((point) => ( + +
+
{point.title}
+ {point.description &&
{point.description}
} +
+
+ ))} + {mapPoints.length > 1 && point.position)} />} + + ) : homePosition ? ( <> - You are here - Visit Singapore - + You are here + Visit Singapore + {isOneMap ? : null} - ): - Enable map settings to show your location - } - + ) : ( + Explore Singapore + )}
); }; diff --git a/frontend/src/components/maps/Markers.tsx b/frontend/src/components/maps/Markers.tsx index 958f16f..7ae8d4b 100644 --- a/frontend/src/components/maps/Markers.tsx +++ b/frontend/src/components/maps/Markers.tsx @@ -1,6 +1,7 @@ import homeMarker from "../../assets/markers/homeMarker.svg"; import singaporeMarker from "../../assets/markers/singaporeMarker.svg" +import type { ReactNode } from "react"; import L from "leaflet"; import { Marker, Popup } from "react-leaflet"; @@ -22,7 +23,7 @@ type PositionType = [number, number]; interface markerProps { position: PositionType; - children?: string; + children?: ReactNode; } export const HomeMarker = ({ position, children }: markerProps) => { @@ -41,3 +42,11 @@ export const SingaporeMarker = ({children}: {children?:string}) => { ); }; +export const AttractionMarker = ({ position, children }: markerProps) => { + return ( + + {children && {children}} + + ); +}; + diff --git a/frontend/src/components/navigation/Navbar.tsx b/frontend/src/components/navigation/Navbar.tsx index e946423..60f7617 100644 --- a/frontend/src/components/navigation/Navbar.tsx +++ b/frontend/src/components/navigation/Navbar.tsx @@ -168,12 +168,12 @@ const Navbar = () => { ) : ( <>
  • - + {t("navigation:main.login")}
  • - + {t("navigation:main.signup")}
  • @@ -221,11 +221,11 @@ const Navbar = () => { ) : ( <> -
  • - {t("navigation:main.login")} +
  • + {t("navigation:main.login")}
  • -
  • - {t("navigation:main.signup")} +
  • + {t("navigation:main.signup")}
  • )} diff --git a/frontend/src/components/navigation/menu.ts b/frontend/src/components/navigation/menu.ts index 5a669d9..a21356d 100644 --- a/frontend/src/components/navigation/menu.ts +++ b/frontend/src/components/navigation/menu.ts @@ -31,12 +31,12 @@ export const NavCenterItems: MenuItem[] = [ { title: "navigation:megaMenu.explore.attractions.title", description: "navigation:megaMenu.explore.attractions.description", - link: "/attractions", + link: "/explore/top-attractions", }, { title: "navigation:megaMenu.explore.neighbourhoods.title", description: "navigation:megaMenu.explore.neighbourhoods.description", - link: "/neighbourhoods", + link: "/explore/neighbourhoods", }, ], }, diff --git a/frontend/src/pages/public/explore/TopAttractions.tsx b/frontend/src/pages/public/explore/TopAttractions.tsx new file mode 100644 index 0000000..87bd38e --- /dev/null +++ b/frontend/src/pages/public/explore/TopAttractions.tsx @@ -0,0 +1,47 @@ +import Layout from "../../layout"; +import Map from "../../../components/maps/Map"; + +const placeholderPoints = [ + { + title: "Marina Bay Sands", + position: [1.2834, 103.8607] as [number, number], + description: "Placeholder attraction marker", + }, + { + title: "Gardens by the Bay", + position: [1.2816, 103.8636] as [number, number], + description: "Placeholder attraction marker", + }, + { + title: "Singapore Flyer", + position: [1.2893, 103.8637] as [number, number], + description: "Placeholder attraction marker", + }, + { + title: "Sentosa", + position: [1.2494, 103.8303] as [number, number], + description: "Placeholder attraction marker", + }, +]; + + +const TopAttractions = () => { + return ( + +
    +
    +

    Top Attractions

    +

    + Discover the must-see attractions in Singapore. These are placeholder + locations for now and will later be replaced with live location data + from an endpoint. +

    +
    + + +
    +
    + ) +} + +export default TopAttractions \ No newline at end of file From 7c1d8e6ff11bcb25e66f33599444e2ccfcd4747e Mon Sep 17 00:00:00 2001 From: stephen leong Date: Sun, 19 Apr 2026 16:52:17 +0100 Subject: [PATCH 03/12] feat: remove redundant div and streamline welcome message in Home component --- frontend/src/pages/public/Home.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/public/Home.tsx b/frontend/src/pages/public/Home.tsx index 3a529cb..3040b7b 100644 --- a/frontend/src/pages/public/Home.tsx +++ b/frontend/src/pages/public/Home.tsx @@ -14,11 +14,11 @@ const Home = () => { return ( +

    {t('welcome', { ns: 'system' })}

    -
    Home
    -

    {t('welcome', { ns: 'system' })}

    +

    {formatCurrency(1000 * rate, currency)}

    {t('greeting', { ns: 'system' })}

    From 6c5bb141365db5a454d7dd31e747aaaa96b2839c Mon Sep 17 00:00:00 2001 From: stephen leong Date: Sun, 19 Apr 2026 18:12:45 +0100 Subject: [PATCH 04/12] feat: add attractions route and integrate attractions data fetching in TopAttractions component --- backend/index.ts | 2 + backend/src/routes/public/attractions.ts | 194 ++++++++++++++++++ frontend/src/components/maps/Map.tsx | 81 +++++++- frontend/src/components/maps/Markers.tsx | 28 ++- .../pages/public/explore/TopAttractions.tsx | 145 ++++++++++--- 5 files changed, 418 insertions(+), 32 deletions(-) create mode 100644 backend/src/routes/public/attractions.ts diff --git a/backend/index.ts b/backend/index.ts index 8dd7788..dc88534 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -8,6 +8,7 @@ import authRouter from "./src/routes/public/auth"; import profilesRouter from "./src/routes/authenticated/profiles"; import neighbourhoodsRouter from "./src/routes/public/neighbourhoods"; import eventsRouter from "./src/routes/public/events"; +import attractionsRouter from "./src/routes/public/attractions"; import threadsRouter from "./src/routes/authenticated/forums/threads"; import repliesRouter from "./src/routes/authenticated/forums/replies"; @@ -30,6 +31,7 @@ app.use("/auth", authRouter); app.use("/profiles", profilesRouter); app.use("/neighbourhoods", neighbourhoodsRouter); app.use("/events", eventsRouter); +app.use("/attractions", attractionsRouter); app.use("/threads", threadsRouter); app.use("/threads/:threadId/replies", repliesRouter); diff --git a/backend/src/routes/public/attractions.ts b/backend/src/routes/public/attractions.ts new file mode 100644 index 0000000..286e11e --- /dev/null +++ b/backend/src/routes/public/attractions.ts @@ -0,0 +1,194 @@ +import { Router } from "express"; +import { pool } from "../../db/pool"; +import { consola } from "consola"; + +const router = Router(); + +const DEFAULT_BOUNDS = { + minLat: 1.144, + maxLat: 1.494, + minLng: 103.535, + maxLng: 104.502, +}; + +const parseFloatParam = (value: unknown, fallback: number) => { + if (typeof value !== "string") { + return fallback; + } + + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const parseZoom = (value: unknown, fallback = 11) => { + const parsed = parseFloatParam(value, fallback); + return Math.max(1, Math.min(22, parsed)); +}; + +const parseLimit = (value: unknown, fallback: number, max: number) => { + if (typeof value !== "string") { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + + return Math.min(parsed, max); +}; + +const normalizeBounds = ( + minLat: number, + maxLat: number, + minLng: number, + maxLng: number, +) => ({ + minLat: Math.min(minLat, maxLat), + maxLat: Math.max(minLat, maxLat), + minLng: Math.min(minLng, maxLng), + maxLng: Math.max(minLng, maxLng), +}); + +const getCellSizeByZoom = (zoom: number) => { + if (zoom < 12) return 0.05; + if (zoom < 13) return 0.03; + return 0.015; +}; + +router.get("/", async (req, res) => { + const minLat = parseFloatParam(req.query.minLat, DEFAULT_BOUNDS.minLat); + const maxLat = parseFloatParam(req.query.maxLat, DEFAULT_BOUNDS.maxLat); + const minLng = parseFloatParam(req.query.minLng, DEFAULT_BOUNDS.minLng); + const maxLng = parseFloatParam(req.query.maxLng, DEFAULT_BOUNDS.maxLng); + const zoom = parseZoom(req.query.zoom); + + const bounds = normalizeBounds(minLat, maxLat, minLng, maxLng); + const isClusterMode = zoom < 14; + const limit = isClusterMode + ? parseLimit(req.query.limit, 80, 200) + : parseLimit(req.query.limit, 500, 2000); + + try { + const countResult = await pool.query( + ` + SELECT COUNT(*)::int AS count + FROM public.locations l + WHERE l.latitude IS NOT NULL + AND l.longitude IS NOT NULL + AND l.latitude BETWEEN $1 AND $2 + AND l.longitude BETWEEN $3 AND $4 + `, + [bounds.minLat, bounds.maxLat, bounds.minLng, bounds.maxLng], + ); + + const totalInBounds = Number.parseInt(countResult.rows[0]?.count ?? "0", 10); + + let dataQuery = ""; + let values: Array = []; + + if (isClusterMode) { + const cellSize = getCellSizeByZoom(zoom); + dataQuery = ` + WITH base AS ( + SELECT + l.id, + l.name, + l.description, + l.latitude, + l.longitude, + l.website, + l.location_type_id, + lt.name AS location_type_name, + COALESCE(l.marker_icon, lt.icon) AS marker_icon, + lt.map_color, + FLOOR(l.latitude / $5)::int AS lat_bucket, + FLOOR(l.longitude / $5)::int AS lng_bucket + FROM public.locations l + LEFT JOIN public.location_types lt ON lt.id = l.location_type_id + WHERE l.latitude IS NOT NULL + AND l.longitude IS NOT NULL + AND l.latitude BETWEEN $1 AND $2 + AND l.longitude BETWEEN $3 AND $4 + ), + ranked AS ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY lat_bucket, lng_bucket + ORDER BY name ASC NULLS LAST, id + ) AS row_rank + FROM base + ) + SELECT + id, + name, + description, + latitude, + longitude, + website, + location_type_id, + location_type_name, + marker_icon, + map_color + FROM ranked + WHERE row_rank = 1 + ORDER BY name ASC NULLS LAST, id + LIMIT $6 + `; + values = [ + bounds.minLat, + bounds.maxLat, + bounds.minLng, + bounds.maxLng, + cellSize, + limit, + ]; + } else { + dataQuery = ` + SELECT + l.id, + l.name, + l.description, + l.latitude, + l.longitude, + l.website, + l.location_type_id, + lt.name AS location_type_name, + COALESCE(l.marker_icon, lt.icon) AS marker_icon, + lt.map_color + FROM public.locations l + LEFT JOIN public.location_types lt ON lt.id = l.location_type_id + WHERE l.latitude IS NOT NULL + AND l.longitude IS NOT NULL + AND l.latitude BETWEEN $1 AND $2 + AND l.longitude BETWEEN $3 AND $4 + ORDER BY name ASC NULLS LAST, l.id + LIMIT $5 + `; + values = [ + bounds.minLat, + bounds.maxLat, + bounds.minLng, + bounds.maxLng, + limit, + ]; + } + + const dataResult = await pool.query(dataQuery, values); + + return res.status(200).json({ + zoom, + bounds, + isClusterMode, + totalInBounds, + returnedCount: dataResult.rows.length, + data: dataResult.rows, + }); + } catch (error) { + consola.error("Error fetching attractions:", error); + return res.status(500).json({ error: "Internal Server Error" }); + } +}); + +export default router; diff --git a/frontend/src/components/maps/Map.tsx b/frontend/src/components/maps/Map.tsx index 2b662bf..a946fd8 100644 --- a/frontend/src/components/maps/Map.tsx +++ b/frontend/src/components/maps/Map.tsx @@ -1,4 +1,5 @@ -import { MapContainer, TileLayer } from "react-leaflet"; +import { useEffect } from "react"; +import { MapContainer, TileLayer, useMap, useMapEvents } from "react-leaflet"; import { AttractionMarker, HomeMarker, SingaporeMarker } from "./Markers"; import { FitBounds } from "./utils"; @@ -7,9 +8,21 @@ type PositionType = [number, number]; type BasemapType = "openstreetmap" | "onemap"; type MapPoint = { + id?: string; position: PositionType; title: string; description?: string; + iconUrl?: string | null; + website?: string | null; + typeName?: string | null; +}; + +type MapViewport = { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; + zoom: number; }; const SINGAPORE_CENTER: PositionType = [1.364917, 103.822872]; @@ -29,13 +42,58 @@ interface MapProps { centerPosition?: PositionType; points?: MapPoint[]; basemap?: BasemapType; + onViewportChange?: (viewport: MapViewport) => void; + scrollWheelZoom?: boolean; } +const ViewportEvents = ({ + onViewportChange, +}: { + onViewportChange?: (viewport: MapViewport) => void; +}) => { + const map = useMap(); + + const emitViewport = () => { + if (!onViewportChange) return; + + const bounds = map.getBounds(); + onViewportChange({ + minLat: bounds.getSouth(), + maxLat: bounds.getNorth(), + minLng: bounds.getWest(), + maxLng: bounds.getEast(), + zoom: map.getZoom(), + }); + }; + + useEffect(() => { + if (!onViewportChange) return; + + const bounds = map.getBounds(); + onViewportChange({ + minLat: bounds.getSouth(), + maxLat: bounds.getNorth(), + minLng: bounds.getWest(), + maxLng: bounds.getEast(), + zoom: map.getZoom(), + }); + }, [map, onViewportChange]); + + useMapEvents({ + moveend: emitViewport, + zoomend: emitViewport, + }); + + return null; +}; + const Map = ({ homePosition, centerPosition = SINGAPORE_CENTER, points, basemap = "openstreetmap", + onViewportChange, + scrollWheelZoom = false, }: MapProps) => { const mapPoints = points ?? []; const hasPoints = mapPoints.length > 0; @@ -50,7 +108,7 @@ const Map = ({ maxZoom={isOneMap ? 19 : undefined} maxBounds={isOneMap ? SINGAPORE_BOUNDS : undefined} maxBoundsViscosity={isOneMap ? 1 : undefined} - scrollWheelZoom={false} + scrollWheelZoom={scrollWheelZoom} className="relative z-0 w-full h-100 overflow-hidden rounded-2xl border border-base-300" > + {hasPoints ? ( <> {mapPoints.map((point) => ( - +
    {point.title}
    {point.description &&
    {point.description}
    } + {point.typeName &&
    {point.typeName}
    } + {point.website && ( + + Website + + )}
    ))} - {mapPoints.length > 1 && point.position)} />} ) : homePosition ? ( <> diff --git a/frontend/src/components/maps/Markers.tsx b/frontend/src/components/maps/Markers.tsx index 7ae8d4b..49618d8 100644 --- a/frontend/src/components/maps/Markers.tsx +++ b/frontend/src/components/maps/Markers.tsx @@ -24,8 +24,32 @@ type PositionType = [number, number]; interface markerProps { position: PositionType; children?: ReactNode; + iconUrl?: string | null; } +const customIconCache = new Map(); + +const getMarkerIcon = (iconUrl?: string | null) => { + if (!iconUrl) { + return singaporeIcon; + } + + const cached = customIconCache.get(iconUrl); + if (cached) { + return cached; + } + + const icon = new L.Icon({ + iconUrl, + iconSize: [36, 36], + iconAnchor: [18, 36], + popupAnchor: [0, -36], + }); + + customIconCache.set(iconUrl, icon); + return icon; +}; + export const HomeMarker = ({ position, children }: markerProps) => { return ( @@ -42,9 +66,9 @@ export const SingaporeMarker = ({children}: {children?:string}) => { ); }; -export const AttractionMarker = ({ position, children }: markerProps) => { +export const AttractionMarker = ({ position, children, iconUrl }: markerProps) => { return ( - + {children && {children}} ); diff --git a/frontend/src/pages/public/explore/TopAttractions.tsx b/frontend/src/pages/public/explore/TopAttractions.tsx index 87bd38e..1b40524 100644 --- a/frontend/src/pages/public/explore/TopAttractions.tsx +++ b/frontend/src/pages/public/explore/TopAttractions.tsx @@ -1,44 +1,137 @@ +import { useEffect, useRef, useState } from "react"; import Layout from "../../layout"; import Map from "../../../components/maps/Map"; +import CONFIG from "../../../config"; -const placeholderPoints = [ - { - title: "Marina Bay Sands", - position: [1.2834, 103.8607] as [number, number], - description: "Placeholder attraction marker", - }, - { - title: "Gardens by the Bay", - position: [1.2816, 103.8636] as [number, number], - description: "Placeholder attraction marker", - }, - { - title: "Singapore Flyer", - position: [1.2893, 103.8637] as [number, number], - description: "Placeholder attraction marker", - }, - { - title: "Sentosa", - position: [1.2494, 103.8303] as [number, number], - description: "Placeholder attraction marker", - }, -]; +type AttractionResponse = { + id: string; + name: string | null; + description: string | null; + latitude: number; + longitude: number; + marker_icon: string | null; + website: string | null; + location_type_name: string | null; +}; + +type AttractionsApiResponse = { + isClusterMode: boolean; + totalInBounds: number; + returnedCount: number; + data: AttractionResponse[]; +}; const TopAttractions = () => { + const [points, setPoints] = useState>([]); + const [loading, setLoading] = useState(false); + const [stats, setStats] = useState<{ total: number; visible: number; isClusterMode: boolean }>({ + total: 0, + visible: 0, + isClusterMode: true, + }); + const fetchTimerRef = useRef(null); + + const fetchAttractions = async ( + viewport: { minLat: number; maxLat: number; minLng: number; maxLng: number; zoom: number }, + ) => { + setLoading(true); + try { + const params = new URLSearchParams({ + minLat: String(viewport.minLat), + maxLat: String(viewport.maxLat), + minLng: String(viewport.minLng), + maxLng: String(viewport.maxLng), + zoom: String(viewport.zoom), + }); + + const response = await fetch(`${CONFIG.API_BASE_URL}/attractions?${params.toString()}`); + if (!response.ok) { + throw new Error(`Failed to fetch attractions: ${response.statusText}`); + } + + const payload: AttractionsApiResponse = await response.json(); + setStats({ + total: payload.totalInBounds, + visible: payload.returnedCount, + isClusterMode: payload.isClusterMode, + }); + + setPoints( + payload.data.map((item) => ({ + id: item.id, + title: item.name ?? "Untitled attraction", + description: item.description ?? undefined, + position: [item.latitude, item.longitude], + iconUrl: item.marker_icon, + website: item.website, + typeName: item.location_type_name, + })), + ); + } catch (error) { + console.error("Error fetching attractions:", error); + setPoints([]); + setStats({ total: 0, visible: 0, isClusterMode: true }); + } finally { + setLoading(false); + } + }; + + const handleViewportChange = (viewport: { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; + zoom: number; + }) => { + if (fetchTimerRef.current) { + window.clearTimeout(fetchTimerRef.current); + } + + fetchTimerRef.current = window.setTimeout(() => { + void fetchAttractions(viewport); + }, 250); + }; + + useEffect(() => { + return () => { + if (fetchTimerRef.current) { + window.clearTimeout(fetchTimerRef.current); + } + }; + }, []); + return (

    Top Attractions

    - Discover the must-see attractions in Singapore. These are placeholder - locations for now and will later be replaced with live location data - from an endpoint. + Discover the must-see attractions in Singapore. Markers are loaded by + visible map area and zoom level. +

    +

    + {loading + ? "Loading markers..." + : `${stats.visible} markers shown in view (${stats.total} attractions in this area).`} + {stats.isClusterMode ? " Zoom in to reveal more attractions." : ""}

    - +
    ) From 9665187514957dfc3c8474d22017e1825408ae47 Mon Sep 17 00:00:00 2001 From: stephen leong Date: Sun, 19 Apr 2026 18:19:10 +0100 Subject: [PATCH 05/12] feat: add mapColor property to MapPoint and update AttractionMarker for color customization --- frontend/src/components/maps/Map.tsx | 93 ++++++++++++++----- frontend/src/components/maps/Markers.tsx | 52 ++++++++++- .../pages/public/explore/TopAttractions.tsx | 3 + 3 files changed, 119 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/maps/Map.tsx b/frontend/src/components/maps/Map.tsx index a946fd8..1653d68 100644 --- a/frontend/src/components/maps/Map.tsx +++ b/frontend/src/components/maps/Map.tsx @@ -13,6 +13,7 @@ type MapPoint = { title: string; description?: string; iconUrl?: string | null; + mapColor?: string | null; website?: string | null; typeName?: string | null; }; @@ -87,6 +88,72 @@ const ViewportEvents = ({ return null; }; +const getMinDistancePx = (zoom: number) => { + if (zoom >= 18) return 10; + if (zoom >= 16) return 14; + if (zoom >= 14) return 20; + return 28; +}; + +const AttractionPopupContent = ({ point }: { point: MapPoint }) => { + return ( +
    +
    {point.title}
    + {point.description &&
    {point.description}
    } + {point.typeName &&
    {point.typeName}
    } + {point.website && ( + + Website + + )} +
    + ); +}; + +const AttractionMarkersLayer = ({ points }: { points: MapPoint[] }) => { + const map = useMap(); + const zoom = map.getZoom(); + const minimumDistancePx = getMinDistancePx(zoom); + + const accepted: Array<{ point: MapPoint; x: number; y: number }> = []; + + for (const point of points) { + const projected = map.latLngToLayerPoint(point.position); + const markerDistancePx = point.iconUrl ? minimumDistancePx + 4 : minimumDistancePx; + const markerDistanceSquared = markerDistancePx * markerDistancePx; + + const overlapsExisting = accepted.some((entry) => { + const dx = entry.x - projected.x; + const dy = entry.y - projected.y; + return dx * dx + dy * dy < markerDistanceSquared; + }); + + if (!overlapsExisting) { + accepted.push({ point, x: projected.x, y: projected.y }); + } + } + + return ( + <> + {accepted.map(({ point }) => ( + + + + ))} + + ); +}; + const Map = ({ homePosition, centerPosition = SINGAPORE_CENTER, @@ -124,31 +191,7 @@ const Map = ({ /> {hasPoints ? ( - <> - {mapPoints.map((point) => ( - -
    -
    {point.title}
    - {point.description &&
    {point.description}
    } - {point.typeName &&
    {point.typeName}
    } - {point.website && ( - - Website - - )} -
    -
    - ))} - + ) : homePosition ? ( <> You are here diff --git a/frontend/src/components/maps/Markers.tsx b/frontend/src/components/maps/Markers.tsx index 49618d8..68f00bb 100644 --- a/frontend/src/components/maps/Markers.tsx +++ b/frontend/src/components/maps/Markers.tsx @@ -25,13 +25,57 @@ interface markerProps { position: PositionType; children?: ReactNode; iconUrl?: string | null; + mapColor?: string | null; } const customIconCache = new Map(); +const customDivIconCache = new Map(); -const getMarkerIcon = (iconUrl?: string | null) => { +const isHexColor = (value: string) => /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(value); + +const normalizeHexColor = (value?: string | null) => { + if (!value) return null; + + const trimmed = value.trim(); + if (!trimmed) return null; + + const withHash = trimmed.startsWith("#") ? trimmed : `#${trimmed}`; + return isHexColor(withHash) ? withHash : null; +}; + +const getColorMarkerIcon = (mapColor?: string | null) => { + const normalizedColor = normalizeHexColor(mapColor) ?? "#EE2536"; + const cacheKey = `color:${normalizedColor}`; + + const cached = customDivIconCache.get(cacheKey); + if (cached) { + return cached; + } + + const icon = L.divIcon({ + className: "", + html: ` +
    + `, + iconSize: [20, 20], + iconAnchor: [10, 10], + popupAnchor: [0, -10], + }); + + customDivIconCache.set(cacheKey, icon); + return icon; +}; + +const getMarkerIcon = (iconUrl?: string | null, mapColor?: string | null) => { if (!iconUrl) { - return singaporeIcon; + return getColorMarkerIcon(mapColor); } const cached = customIconCache.get(iconUrl); @@ -66,9 +110,9 @@ export const SingaporeMarker = ({children}: {children?:string}) => { ); }; -export const AttractionMarker = ({ position, children, iconUrl }: markerProps) => { +export const AttractionMarker = ({ position, children, iconUrl, mapColor }: markerProps) => { return ( - + {children && {children}} ); diff --git a/frontend/src/pages/public/explore/TopAttractions.tsx b/frontend/src/pages/public/explore/TopAttractions.tsx index 1b40524..1cb2923 100644 --- a/frontend/src/pages/public/explore/TopAttractions.tsx +++ b/frontend/src/pages/public/explore/TopAttractions.tsx @@ -10,6 +10,7 @@ type AttractionResponse = { latitude: number; longitude: number; marker_icon: string | null; + map_color: string | null; website: string | null; location_type_name: string | null; }; @@ -29,6 +30,7 @@ const TopAttractions = () => { description?: string; position: [number, number]; iconUrl?: string | null; + mapColor?: string | null; website?: string | null; typeName?: string | null; }>>([]); @@ -72,6 +74,7 @@ const TopAttractions = () => { description: item.description ?? undefined, position: [item.latitude, item.longitude], iconUrl: item.marker_icon, + mapColor: item.map_color, website: item.website, typeName: item.location_type_name, })), From 2e8a9be8524be73cbce3fe6a447d9775bc7150e6 Mon Sep 17 00:00:00 2001 From: stephen leong Date: Sun, 19 Apr 2026 18:23:47 +0100 Subject: [PATCH 06/12] feat: enhance attractions fetching with caching and cluster mode support --- backend/src/routes/public/attractions.ts | 15 +- .../pages/public/explore/TopAttractions.tsx | 214 +++++++++++++----- 2 files changed, 174 insertions(+), 55 deletions(-) diff --git a/backend/src/routes/public/attractions.ts b/backend/src/routes/public/attractions.ts index 286e11e..23bc4d0 100644 --- a/backend/src/routes/public/attractions.ts +++ b/backend/src/routes/public/attractions.ts @@ -38,6 +38,16 @@ const parseLimit = (value: unknown, fallback: number, max: number) => { return Math.min(parsed, max); }; +const parseBooleanParam = (value: unknown) => { + if (typeof value !== "string") { + return undefined; + } + + if (value === "true") return true; + if (value === "false") return false; + return undefined; +}; + const normalizeBounds = ( minLat: number, maxLat: number, @@ -62,12 +72,13 @@ router.get("/", async (req, res) => { const minLng = parseFloatParam(req.query.minLng, DEFAULT_BOUNDS.minLng); const maxLng = parseFloatParam(req.query.maxLng, DEFAULT_BOUNDS.maxLng); const zoom = parseZoom(req.query.zoom); + const clusterOverride = parseBooleanParam(req.query.cluster); const bounds = normalizeBounds(minLat, maxLat, minLng, maxLng); - const isClusterMode = zoom < 14; + const isClusterMode = clusterOverride ?? zoom < 14; const limit = isClusterMode ? parseLimit(req.query.limit, 80, 200) - : parseLimit(req.query.limit, 500, 2000); + : parseLimit(req.query.limit, 2000, 10000); try { const countResult = await pool.query( diff --git a/frontend/src/pages/public/explore/TopAttractions.tsx b/frontend/src/pages/public/explore/TopAttractions.tsx index 1cb2923..8d4136a 100644 --- a/frontend/src/pages/public/explore/TopAttractions.tsx +++ b/frontend/src/pages/public/explore/TopAttractions.tsx @@ -1,8 +1,18 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Layout from "../../layout"; -import Map from "../../../components/maps/Map"; +import AttractionsMap from "../../../components/maps/Map"; import CONFIG from "../../../config"; +const SINGAPORE_BOUNDS = { + minLat: 1.144, + maxLat: 1.494, + minLng: 103.535, + maxLng: 104.502, +}; + +const CACHE_KEY = "top-attractions-cache-v1"; +const CACHE_TTL_MS = 10 * 60 * 1000; + type AttractionResponse = { id: string; name: string | null; @@ -22,37 +32,144 @@ type AttractionsApiResponse = { data: AttractionResponse[]; }; +type MapPoint = { + id: string; + title: string; + description?: string; + position: [number, number]; + iconUrl?: string | null; + mapColor?: string | null; + website?: string | null; + typeName?: string | null; +}; + +type Viewport = { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; + zoom: number; +}; + +type CachedAttractionsPayload = { + timestamp: number; + points: MapPoint[]; +}; + +const mapApiPoints = (data: AttractionResponse[]): MapPoint[] => + data.map((item) => ({ + id: item.id, + title: item.name ?? "Untitled attraction", + description: item.description ?? undefined, + position: [item.latitude, item.longitude], + iconUrl: item.marker_icon, + mapColor: item.map_color, + website: item.website, + typeName: item.location_type_name, + })); + +const getCachedAttractions = (): MapPoint[] | null => { + try { + const rawCache = window.localStorage.getItem(CACHE_KEY); + if (!rawCache) return null; + + const parsed = JSON.parse(rawCache) as CachedAttractionsPayload; + if (!parsed?.timestamp || !Array.isArray(parsed.points)) return null; + if (Date.now() - parsed.timestamp > CACHE_TTL_MS) return null; + + return parsed.points; + } catch { + return null; + } +}; + +const setCachedAttractions = (points: MapPoint[]) => { + const payload: CachedAttractionsPayload = { + timestamp: Date.now(), + points, + }; + + window.localStorage.setItem(CACHE_KEY, JSON.stringify(payload)); +}; + +const getCellSizeByZoom = (zoom: number) => { + if (zoom < 12) return 0.05; + if (zoom < 13) return 0.03; + return 0.015; +}; + +const selectViewportPoints = (points: MapPoint[], viewport: Viewport | null) => { + if (!viewport) { + return points.slice(0, 80); + } + + const filtered = points.filter((point) => { + const [lat, lng] = point.position; + return ( + lat >= viewport.minLat && + lat <= viewport.maxLat && + lng >= viewport.minLng && + lng <= viewport.maxLng + ); + }); + + if (viewport.zoom >= 14) { + return filtered; + } + + const cellSize = getCellSizeByZoom(viewport.zoom); + const bucketed = new Map(); + + for (const point of filtered) { + const [lat, lng] = point.position; + const latBucket = Math.floor(lat / cellSize); + const lngBucket = Math.floor(lng / cellSize); + const key = `${latBucket}-${lngBucket}`; + + if (!bucketed.has(key)) { + bucketed.set(key, point); + } + } + + return [...bucketed.values()]; +}; + const TopAttractions = () => { - const [points, setPoints] = useState>([]); + const [allPoints, setAllPoints] = useState([]); + const [viewport, setViewport] = useState(null); const [loading, setLoading] = useState(false); - const [stats, setStats] = useState<{ total: number; visible: number; isClusterMode: boolean }>({ + const [stats, setStats] = useState<{ total: number; visible: number; isClusterMode: boolean }>( { total: 0, visible: 0, isClusterMode: true, }); - const fetchTimerRef = useRef(null); + const refreshIntervalRef = useRef(null); + + const visiblePoints = useMemo( + () => selectViewportPoints(allPoints, viewport), + [allPoints, viewport], + ); - const fetchAttractions = async ( - viewport: { minLat: number; maxLat: number; minLng: number; maxLng: number; zoom: number }, - ) => { + useEffect(() => { + setStats({ + total: allPoints.length, + visible: visiblePoints.length, + isClusterMode: viewport ? viewport.zoom < 14 : true, + }); + }, [allPoints.length, visiblePoints.length, viewport]); + + const fetchAttractions = async () => { setLoading(true); try { const params = new URLSearchParams({ - minLat: String(viewport.minLat), - maxLat: String(viewport.maxLat), - minLng: String(viewport.minLng), - maxLng: String(viewport.maxLng), - zoom: String(viewport.zoom), + minLat: String(SINGAPORE_BOUNDS.minLat), + maxLat: String(SINGAPORE_BOUNDS.maxLat), + minLng: String(SINGAPORE_BOUNDS.minLng), + maxLng: String(SINGAPORE_BOUNDS.maxLng), + zoom: "20", + cluster: "false", + limit: "10000", }); const response = await fetch(`${CONFIG.API_BASE_URL}/attractions?${params.toString()}`); @@ -61,53 +178,44 @@ const TopAttractions = () => { } const payload: AttractionsApiResponse = await response.json(); - setStats({ - total: payload.totalInBounds, - visible: payload.returnedCount, - isClusterMode: payload.isClusterMode, - }); + const mappedPoints = mapApiPoints(payload.data); - setPoints( - payload.data.map((item) => ({ - id: item.id, - title: item.name ?? "Untitled attraction", - description: item.description ?? undefined, - position: [item.latitude, item.longitude], - iconUrl: item.marker_icon, - mapColor: item.map_color, - website: item.website, - typeName: item.location_type_name, - })), - ); + setAllPoints(mappedPoints); + setCachedAttractions(mappedPoints); } catch (error) { console.error("Error fetching attractions:", error); - setPoints([]); + setAllPoints([]); setStats({ total: 0, visible: 0, isClusterMode: true }); } finally { setLoading(false); } }; - const handleViewportChange = (viewport: { + const handleViewportChange = (nextViewport: { minLat: number; maxLat: number; minLng: number; maxLng: number; zoom: number; }) => { - if (fetchTimerRef.current) { - window.clearTimeout(fetchTimerRef.current); - } - - fetchTimerRef.current = window.setTimeout(() => { - void fetchAttractions(viewport); - }, 250); + setViewport(nextViewport); }; useEffect(() => { + const cached = getCachedAttractions(); + if (cached) { + setAllPoints(cached); + } else { + void fetchAttractions(); + } + + refreshIntervalRef.current = window.setInterval(() => { + void fetchAttractions(); + }, CACHE_TTL_MS); + return () => { - if (fetchTimerRef.current) { - window.clearTimeout(fetchTimerRef.current); + if (refreshIntervalRef.current) { + window.clearInterval(refreshIntervalRef.current); } }; }, []); @@ -124,14 +232,14 @@ const TopAttractions = () => {

    {loading ? "Loading markers..." - : `${stats.visible} markers shown in view (${stats.total} attractions in this area).`} + : `${stats.visible} markers shown in view (${stats.total} attractions cached).`} {stats.isClusterMode ? " Zoom in to reveal more attractions." : ""}

    - From 778a1ffd876d683d6edf77deab83fbcaed579255 Mon Sep 17 00:00:00 2001 From: stephen leong Date: Sun, 19 Apr 2026 18:30:02 +0100 Subject: [PATCH 07/12] feat: add map_priority to attractions and update sorting logic for improved display --- backend/src/routes/public/attractions.ts | 9 ++++-- frontend/src/components/maps/Map.tsx | 6 +++- .../pages/public/explore/TopAttractions.tsx | 30 ++++++++++++------- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/backend/src/routes/public/attractions.ts b/backend/src/routes/public/attractions.ts index 23bc4d0..8ac40d4 100644 --- a/backend/src/routes/public/attractions.ts +++ b/backend/src/routes/public/attractions.ts @@ -109,6 +109,7 @@ router.get("/", async (req, res) => { l.latitude, l.longitude, l.website, + l.map_priority, l.location_type_id, lt.name AS location_type_name, COALESCE(l.marker_icon, lt.icon) AS marker_icon, @@ -127,7 +128,7 @@ router.get("/", async (req, res) => { *, ROW_NUMBER() OVER ( PARTITION BY lat_bucket, lng_bucket - ORDER BY name ASC NULLS LAST, id + ORDER BY map_priority DESC NULLS LAST, name ASC NULLS LAST, id ) AS row_rank FROM base ) @@ -138,13 +139,14 @@ router.get("/", async (req, res) => { latitude, longitude, website, + map_priority, location_type_id, location_type_name, marker_icon, map_color FROM ranked WHERE row_rank = 1 - ORDER BY name ASC NULLS LAST, id + ORDER BY map_priority DESC NULLS LAST, name ASC NULLS LAST, id LIMIT $6 `; values = [ @@ -164,6 +166,7 @@ router.get("/", async (req, res) => { l.latitude, l.longitude, l.website, + l.map_priority, l.location_type_id, lt.name AS location_type_name, COALESCE(l.marker_icon, lt.icon) AS marker_icon, @@ -174,7 +177,7 @@ router.get("/", async (req, res) => { AND l.longitude IS NOT NULL AND l.latitude BETWEEN $1 AND $2 AND l.longitude BETWEEN $3 AND $4 - ORDER BY name ASC NULLS LAST, l.id + ORDER BY l.map_priority DESC NULLS LAST, name ASC NULLS LAST, l.id LIMIT $5 `; values = [ diff --git a/frontend/src/components/maps/Map.tsx b/frontend/src/components/maps/Map.tsx index 1653d68..ef29e6c 100644 --- a/frontend/src/components/maps/Map.tsx +++ b/frontend/src/components/maps/Map.tsx @@ -12,6 +12,7 @@ type MapPoint = { position: PositionType; title: string; description?: string; + priority?: number; iconUrl?: string | null; mapColor?: string | null; website?: string | null; @@ -119,10 +120,13 @@ const AttractionMarkersLayer = ({ points }: { points: MapPoint[] }) => { const map = useMap(); const zoom = map.getZoom(); const minimumDistancePx = getMinDistancePx(zoom); + const sortedPoints = [...points].sort( + (a, b) => (b.priority ?? 0) - (a.priority ?? 0), + ); const accepted: Array<{ point: MapPoint; x: number; y: number }> = []; - for (const point of points) { + for (const point of sortedPoints) { const projected = map.latLngToLayerPoint(point.position); const markerDistancePx = point.iconUrl ? minimumDistancePx + 4 : minimumDistancePx; const markerDistanceSquared = markerDistancePx * markerDistancePx; diff --git a/frontend/src/pages/public/explore/TopAttractions.tsx b/frontend/src/pages/public/explore/TopAttractions.tsx index 8d4136a..7ba1148 100644 --- a/frontend/src/pages/public/explore/TopAttractions.tsx +++ b/frontend/src/pages/public/explore/TopAttractions.tsx @@ -21,6 +21,7 @@ type AttractionResponse = { longitude: number; marker_icon: string | null; map_color: string | null; + map_priority: number | null; website: string | null; location_type_name: string | null; }; @@ -37,6 +38,7 @@ type MapPoint = { title: string; description?: string; position: [number, number]; + priority: number; iconUrl?: string | null; mapColor?: string | null; website?: string | null; @@ -57,16 +59,19 @@ type CachedAttractionsPayload = { }; const mapApiPoints = (data: AttractionResponse[]): MapPoint[] => - data.map((item) => ({ - id: item.id, - title: item.name ?? "Untitled attraction", - description: item.description ?? undefined, - position: [item.latitude, item.longitude], - iconUrl: item.marker_icon, - mapColor: item.map_color, - website: item.website, - typeName: item.location_type_name, - })); + data + .map((item) => ({ + id: item.id, + title: item.name ?? "Untitled attraction", + description: item.description ?? undefined, + position: [item.latitude, item.longitude] as [number, number], + priority: item.map_priority ?? 0, + iconUrl: item.marker_icon, + mapColor: item.map_color, + website: item.website, + typeName: item.location_type_name, + })) + .sort((a, b) => a.priority - b.priority); const getCachedAttractions = (): MapPoint[] | null => { try { @@ -113,6 +118,8 @@ const selectViewportPoints = (points: MapPoint[], viewport: Viewport | null) => ); }); + filtered.sort((a, b) => a.priority - b.priority); + if (viewport.zoom >= 14) { return filtered; } @@ -126,7 +133,8 @@ const selectViewportPoints = (points: MapPoint[], viewport: Viewport | null) => const lngBucket = Math.floor(lng / cellSize); const key = `${latBucket}-${lngBucket}`; - if (!bucketed.has(key)) { + const current = bucketed.get(key); + if (!current || point.priority > current.priority) { bucketed.set(key, point); } } From d845c8e2a35219a5261888e9e5d68d695994eeaa Mon Sep 17 00:00:00 2001 From: stephen leong Date: Sun, 19 Apr 2026 18:38:28 +0100 Subject: [PATCH 08/12] feat: update routing for explore section and enhance viewport event handling in Map component --- frontend/src/App.tsx | 6 ++++-- frontend/src/components/maps/Map.tsx | 25 +++++++++++-------------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6820966..0ec0e87 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,8 @@ import TopAttractions from "./pages/public/explore/TopAttractions"; const KNOWN_UNLOCALIZED_PATH_PATTERNS = [ /^\/$/, /^\/events(?:\/[^/]+)?\/?$/, + /^\/explore\/top-attractions\/?$/, + /^\/explore\/neighbourhoods\/?$/, /^\/neighbourhoods\/?$/, /^\/auth\/?$/, /^\/travel-advice\/pre-travel-checklist\/?$/, @@ -159,13 +161,13 @@ function App() { } /> } /> - + }> } /> } /> } /> - + }> } diff --git a/frontend/src/components/maps/Map.tsx b/frontend/src/components/maps/Map.tsx index ef29e6c..18f4937 100644 --- a/frontend/src/components/maps/Map.tsx +++ b/frontend/src/components/maps/Map.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { MapContainer, TileLayer, useMap, useMapEvents } from "react-leaflet"; import { AttractionMarker, HomeMarker, SingaporeMarker } from "./Markers"; import { FitBounds } from "./utils"; @@ -54,12 +54,18 @@ const ViewportEvents = ({ onViewportChange?: (viewport: MapViewport) => void; }) => { const map = useMap(); + const onViewportChangeRef = useRef(onViewportChange); + + useEffect(() => { + onViewportChangeRef.current = onViewportChange; + }, [onViewportChange]); const emitViewport = () => { - if (!onViewportChange) return; + const callback = onViewportChangeRef.current; + if (!callback) return; const bounds = map.getBounds(); - onViewportChange({ + callback({ minLat: bounds.getSouth(), maxLat: bounds.getNorth(), minLng: bounds.getWest(), @@ -69,17 +75,8 @@ const ViewportEvents = ({ }; useEffect(() => { - if (!onViewportChange) return; - - const bounds = map.getBounds(); - onViewportChange({ - minLat: bounds.getSouth(), - maxLat: bounds.getNorth(), - minLng: bounds.getWest(), - maxLng: bounds.getEast(), - zoom: map.getZoom(), - }); - }, [map, onViewportChange]); + emitViewport(); + }, [map]); useMapEvents({ moveend: emitViewport, From 8bfa613bc6696d124fdf19780092e1ebac73f178 Mon Sep 17 00:00:00 2001 From: stephen leong Date: Sun, 19 Apr 2026 18:44:44 +0100 Subject: [PATCH 09/12] feat: adjust sorting logic for attractions by changing priority order and update viewport point selection criteria --- backend/src/routes/public/attractions.ts | 4 ++-- frontend/src/components/maps/Map.tsx | 2 +- frontend/src/pages/public/explore/TopAttractions.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/public/attractions.ts b/backend/src/routes/public/attractions.ts index 8ac40d4..c6c27e0 100644 --- a/backend/src/routes/public/attractions.ts +++ b/backend/src/routes/public/attractions.ts @@ -128,7 +128,7 @@ router.get("/", async (req, res) => { *, ROW_NUMBER() OVER ( PARTITION BY lat_bucket, lng_bucket - ORDER BY map_priority DESC NULLS LAST, name ASC NULLS LAST, id + ORDER BY map_priority ASC NULLS LAST, name ASC NULLS LAST, id ) AS row_rank FROM base ) @@ -146,7 +146,7 @@ router.get("/", async (req, res) => { map_color FROM ranked WHERE row_rank = 1 - ORDER BY map_priority DESC NULLS LAST, name ASC NULLS LAST, id + ORDER BY map_priority ASC NULLS LAST, name ASC NULLS LAST, id LIMIT $6 `; values = [ diff --git a/frontend/src/components/maps/Map.tsx b/frontend/src/components/maps/Map.tsx index 18f4937..444b8d0 100644 --- a/frontend/src/components/maps/Map.tsx +++ b/frontend/src/components/maps/Map.tsx @@ -118,7 +118,7 @@ const AttractionMarkersLayer = ({ points }: { points: MapPoint[] }) => { const zoom = map.getZoom(); const minimumDistancePx = getMinDistancePx(zoom); const sortedPoints = [...points].sort( - (a, b) => (b.priority ?? 0) - (a.priority ?? 0), + (a, b) => (a.priority ?? 0) - (b.priority ?? 0), ); const accepted: Array<{ point: MapPoint; x: number; y: number }> = []; diff --git a/frontend/src/pages/public/explore/TopAttractions.tsx b/frontend/src/pages/public/explore/TopAttractions.tsx index 7ba1148..8134cb8 100644 --- a/frontend/src/pages/public/explore/TopAttractions.tsx +++ b/frontend/src/pages/public/explore/TopAttractions.tsx @@ -134,7 +134,7 @@ const selectViewportPoints = (points: MapPoint[], viewport: Viewport | null) => const key = `${latBucket}-${lngBucket}`; const current = bucketed.get(key); - if (!current || point.priority > current.priority) { + if (!current || point.priority < current.priority) { bucketed.set(key, point); } } From 3d7e8a18d782af9ecd7ee49e4be3b97b81664680 Mon Sep 17 00:00:00 2001 From: stephen leong Date: Sun, 19 Apr 2026 18:50:20 +0100 Subject: [PATCH 10/12] fix: correct import path for LocationContext in Home and App components feat: add LocationContext for managing user location with geolocation and cookies --- frontend/src/App.tsx | 2 +- frontend/src/components/maps/Map.tsx | 24 ++++++++++++++----- ...LocationContet.tsx => LocationContext.tsx} | 0 frontend/src/pages/public/Home.tsx | 2 +- 4 files changed, 20 insertions(+), 8 deletions(-) rename frontend/src/context/{LocationContet.tsx => LocationContext.tsx} (100%) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0ec0e87..cd5a071 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,7 +9,7 @@ import PreTravelChecklist from "./pages/public/travel-advice/PreTravelChecklist" import Auth from "./pages/public/Auth"; import { CurrencyProvider } from "./context/CurrencyContext"; -import { LocationProvider } from "./context/LocationContet"; +import { LocationProvider } from "./context/LocationContext"; import { AuthProvider } from "./context/AuthContext"; import ProtectedRoute from "./pages/ProtectedRoute"; import Profile from "./pages/authenticated/Profile"; diff --git a/frontend/src/components/maps/Map.tsx b/frontend/src/components/maps/Map.tsx index 444b8d0..5f5004f 100644 --- a/frontend/src/components/maps/Map.tsx +++ b/frontend/src/components/maps/Map.tsx @@ -98,7 +98,9 @@ const AttractionPopupContent = ({ point }: { point: MapPoint }) => {
    {point.title}
    {point.description &&
    {point.description}
    } - {point.typeName &&
    {point.typeName}
    } + {point.typeName && ( +
    {point.typeName}
    + )} {point.website && ( { for (const point of sortedPoints) { const projected = map.latLngToLayerPoint(point.position); - const markerDistancePx = point.iconUrl ? minimumDistancePx + 4 : minimumDistancePx; + const markerDistancePx = point.iconUrl + ? minimumDistancePx + 4 + : minimumDistancePx; const markerDistanceSquared = markerDistancePx * markerDistancePx; const overlapsExisting = accepted.some((entry) => { @@ -143,7 +147,10 @@ const AttractionMarkersLayer = ({ points }: { points: MapPoint[] }) => { <> {accepted.map(({ point }) => ( ) : homePosition ? ( <> - You are here - Visit Singapore - {isOneMap ? : null} + {isOneMap ? ( + + ) : ( + <> + You are here + Visit Singapore + + )} ) : ( Explore Singapore diff --git a/frontend/src/context/LocationContet.tsx b/frontend/src/context/LocationContext.tsx similarity index 100% rename from frontend/src/context/LocationContet.tsx rename to frontend/src/context/LocationContext.tsx diff --git a/frontend/src/pages/public/Home.tsx b/frontend/src/pages/public/Home.tsx index 3040b7b..03afb49 100644 --- a/frontend/src/pages/public/Home.tsx +++ b/frontend/src/pages/public/Home.tsx @@ -4,7 +4,7 @@ import { useCurrency } from "../../context/CurrencyContext"; import { formatCurrency } from "../../utils/formatCurrency"; import HeroSection from "../../components/globals/HeroSection"; import Map from "../../components/maps/Map"; -import { useLocation } from "../../context/LocationContet"; +import { useLocation } from "../../context/LocationContext"; const Home = () => { From f1f2e39014312734d8bca1aa35300c1a91b573c1 Mon Sep 17 00:00:00 2001 From: stephen leong Date: Sun, 19 Apr 2026 19:19:58 +0100 Subject: [PATCH 11/12] feat: add locations route and integrate location types fetching in TopAttractions component --- backend/index.ts | 2 + backend/src/routes/public/locations.ts | 30 +++ frontend/src/components/maps/Map.tsx | 2 +- .../pages/public/explore/TopAttractions.tsx | 248 +++++++++++++++++- 4 files changed, 268 insertions(+), 14 deletions(-) create mode 100644 backend/src/routes/public/locations.ts diff --git a/backend/index.ts b/backend/index.ts index dc88534..b8b4f58 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -9,6 +9,7 @@ import profilesRouter from "./src/routes/authenticated/profiles"; import neighbourhoodsRouter from "./src/routes/public/neighbourhoods"; import eventsRouter from "./src/routes/public/events"; import attractionsRouter from "./src/routes/public/attractions"; +import locationsRouter from "./src/routes/public/locations"; import threadsRouter from "./src/routes/authenticated/forums/threads"; import repliesRouter from "./src/routes/authenticated/forums/replies"; @@ -32,6 +33,7 @@ app.use("/profiles", profilesRouter); app.use("/neighbourhoods", neighbourhoodsRouter); app.use("/events", eventsRouter); app.use("/attractions", attractionsRouter); +app.use("/locations", locationsRouter); app.use("/threads", threadsRouter); app.use("/threads/:threadId/replies", repliesRouter); diff --git a/backend/src/routes/public/locations.ts b/backend/src/routes/public/locations.ts new file mode 100644 index 0000000..3a25fdd --- /dev/null +++ b/backend/src/routes/public/locations.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import { consola } from "consola"; +import { pool } from "../../db/pool"; + +const router = Router(); + +router.get("/types", async (_req, res) => { + try { + const result = await pool.query( + ` + SELECT + id, + name, + description, + icon, + map_color, + created_at + FROM public.location_types + ORDER BY name ASC NULLS LAST, id ASC + `, + ); + + return res.status(200).json({ data: result.rows }); + } catch (error) { + consola.error("Error fetching location types:", error); + return res.status(500).json({ error: "Internal Server Error" }); + } +}); + +export default router; \ No newline at end of file diff --git a/frontend/src/components/maps/Map.tsx b/frontend/src/components/maps/Map.tsx index 5f5004f..3489724 100644 --- a/frontend/src/components/maps/Map.tsx +++ b/frontend/src/components/maps/Map.tsx @@ -212,7 +212,7 @@ const Map = ({ )} ) : ( - Explore Singapore + null )} ); diff --git a/frontend/src/pages/public/explore/TopAttractions.tsx b/frontend/src/pages/public/explore/TopAttractions.tsx index 8134cb8..b7289cc 100644 --- a/frontend/src/pages/public/explore/TopAttractions.tsx +++ b/frontend/src/pages/public/explore/TopAttractions.tsx @@ -10,7 +10,7 @@ const SINGAPORE_BOUNDS = { maxLng: 104.502, }; -const CACHE_KEY = "top-attractions-cache-v1"; +const CACHE_KEY = "top-attractions-cache-v2"; const CACHE_TTL_MS = 10 * 60 * 1000; type AttractionResponse = { @@ -19,6 +19,7 @@ type AttractionResponse = { description: string | null; latitude: number; longitude: number; + location_type_id: string | null; marker_icon: string | null; map_color: string | null; map_priority: number | null; @@ -26,6 +27,15 @@ type AttractionResponse = { location_type_name: string | null; }; +type LocationType = { + id: string; + name: string; + description: string | null; + icon: string | null; + map_color: string | null; + created_at: string | null; +}; + type AttractionsApiResponse = { isClusterMode: boolean; totalInBounds: number; @@ -39,6 +49,7 @@ type MapPoint = { description?: string; position: [number, number]; priority: number; + typeId?: string | null; iconUrl?: string | null; mapColor?: string | null; website?: string | null; @@ -58,6 +69,12 @@ type CachedAttractionsPayload = { points: MapPoint[]; }; +const normalizeTypeId = (value: string | null | undefined) => { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + return normalized.length > 0 ? normalized : null; +}; + const mapApiPoints = (data: AttractionResponse[]): MapPoint[] => data .map((item) => ({ @@ -66,6 +83,7 @@ const mapApiPoints = (data: AttractionResponse[]): MapPoint[] => description: item.description ?? undefined, position: [item.latitude, item.longitude] as [number, number], priority: item.map_priority ?? 0, + typeId: normalizeTypeId(item.location_type_id), iconUrl: item.marker_icon, mapColor: item.map_color, website: item.website, @@ -82,7 +100,14 @@ const getCachedAttractions = (): MapPoint[] | null => { if (!parsed?.timestamp || !Array.isArray(parsed.points)) return null; if (Date.now() - parsed.timestamp > CACHE_TTL_MS) return null; - return parsed.points; + // Ensure cached entries still include type information used by filtering. + const hasTypeIds = parsed.points.some((point) => normalizeTypeId(point.typeId) !== null); + if (!hasTypeIds) return null; + + return parsed.points.map((point) => ({ + ...point, + typeId: normalizeTypeId(point.typeId), + })); } catch { return null; } @@ -145,8 +170,11 @@ const selectViewportPoints = (points: MapPoint[], viewport: Viewport | null) => const TopAttractions = () => { const [allPoints, setAllPoints] = useState([]); + const [locationTypes, setLocationTypes] = useState([]); + const [selectedTypeIds, setSelectedTypeIds] = useState([]); const [viewport, setViewport] = useState(null); const [loading, setLoading] = useState(false); + const [loadingTypes, setLoadingTypes] = useState(false); const [stats, setStats] = useState<{ total: number; visible: number; isClusterMode: boolean }>( { total: 0, visible: 0, @@ -154,18 +182,29 @@ const TopAttractions = () => { }); const refreshIntervalRef = useRef(null); + const filteredPoints = useMemo(() => { + if (selectedTypeIds.length === 0) { + return allPoints; + } + + const selectedSet = new Set(selectedTypeIds); + return allPoints.filter( + (point) => point.typeId && selectedSet.has(point.typeId), + ); + }, [allPoints, selectedTypeIds]); + const visiblePoints = useMemo( - () => selectViewportPoints(allPoints, viewport), - [allPoints, viewport], + () => selectViewportPoints(filteredPoints, viewport), + [filteredPoints, viewport], ); useEffect(() => { setStats({ - total: allPoints.length, + total: filteredPoints.length, visible: visiblePoints.length, isClusterMode: viewport ? viewport.zoom < 14 : true, }); - }, [allPoints.length, visiblePoints.length, viewport]); + }, [filteredPoints.length, visiblePoints.length, viewport]); const fetchAttractions = async () => { setLoading(true); @@ -199,6 +238,29 @@ const TopAttractions = () => { } }; + const fetchLocationTypes = async () => { + setLoadingTypes(true); + try { + const response = await fetch(`${CONFIG.API_BASE_URL}/locations/types`); + if (!response.ok) { + throw new Error(`Failed to fetch location types: ${response.statusText}`); + } + + const payload: { data: LocationType[] } = await response.json(); + setLocationTypes( + (payload.data ?? []).map((type) => ({ + ...type, + id: normalizeTypeId(type.id) ?? type.id, + })), + ); + } catch (error) { + console.error("Error fetching location types:", error); + setLocationTypes([]); + } finally { + setLoadingTypes(false); + } + }; + const handleViewportChange = (nextViewport: { minLat: number; maxLat: number; @@ -210,6 +272,8 @@ const TopAttractions = () => { }; useEffect(() => { + void fetchLocationTypes(); + const cached = getCachedAttractions(); if (cached) { setAllPoints(cached); @@ -228,6 +292,41 @@ const TopAttractions = () => { }; }, []); + const selectedTypeSet = useMemo( + () => new Set(selectedTypeIds.map((id) => normalizeTypeId(id) ?? id)), + [selectedTypeIds], + ); + + const typeNameById = useMemo(() => { + const mapped = new Map(); + for (const type of locationTypes) { + const normalizedId = normalizeTypeId(type.id); + if (!normalizedId) continue; + mapped.set(normalizedId, type.name); + } + return mapped; + }, [locationTypes]); + + const countsByType = useMemo(() => { + const counts = new Map(); + for (const point of allPoints) { + if (!point.typeId) continue; + counts.set(point.typeId, (counts.get(point.typeId) ?? 0) + 1); + } + return counts; + }, [allPoints]); + + const toggleType = (typeId: string) => { + const normalizedTypeId = normalizeTypeId(typeId); + if (!normalizedTypeId) return; + + setSelectedTypeIds((prev) => + prev.includes(normalizedTypeId) + ? prev.filter((item) => item !== normalizedTypeId) + : [...prev, normalizedTypeId], + ); + }; + return (
    @@ -240,17 +339,140 @@ const TopAttractions = () => {

    {loading ? "Loading markers..." - : `${stats.visible} markers shown in view (${stats.total} attractions cached).`} + : `${stats.visible} markers shown in view (${stats.total} attractions after type filter).`} {stats.isClusterMode ? " Zoom in to reveal more attractions." : ""}

    - +
    +
    + +
    + +
    +
    +
    +
    +

    Filter by location type

    + {selectedTypeIds.length > 0 && ( + + )} +
    + + {loadingTypes ? ( +

    Loading types...

    + ) : locationTypes.length === 0 ? ( +

    No location types available.

    + ) : ( +
    + {locationTypes.map((type) => { + const typeId = normalizeTypeId(type.id); + if (!typeId) return null; + + const selected = selectedTypeSet.has(typeId); + const totalForType = countsByType.get(typeId) ?? 0; + + return ( + + ); + })} +
    + )} +
    +
    +
    +
    + +
    +
    +
    +

    Locations

    + + {filteredPoints.length} location{filteredPoints.length === 1 ? "" : "s"} + +
    + + {filteredPoints.length === 0 ? ( +

    + No locations found for the selected type filters. +

    + ) : ( +
    + + + + + + + + + + {filteredPoints.map((point) => ( + + + + + + ))} + +
    NameTypeWebsite
    {point.title} + {typeNameById.get(normalizeTypeId(point.typeId) ?? "") ?? + point.typeName ?? + "Uncategorized"} + + {point.website ? ( + + Visit + + ) : ( + - + )} +
    +
    + )} +
    +
    ) From d4edf65787ea894f9d53ab0c5265827efea215ed Mon Sep 17 00:00:00 2001 From: stephen leong Date: Sun, 19 Apr 2026 19:27:07 +0100 Subject: [PATCH 12/12] feat: add asset versioning to marker icons in map points for updated display --- .../pages/public/explore/TopAttractions.tsx | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/public/explore/TopAttractions.tsx b/frontend/src/pages/public/explore/TopAttractions.tsx index b7289cc..caca8c9 100644 --- a/frontend/src/pages/public/explore/TopAttractions.tsx +++ b/frontend/src/pages/public/explore/TopAttractions.tsx @@ -75,7 +75,23 @@ const normalizeTypeId = (value: string | null | undefined) => { return normalized.length > 0 ? normalized : null; }; -const mapApiPoints = (data: AttractionResponse[]): MapPoint[] => +const addAssetVersionParam = ( + iconUrl: string | null, + version: string, +): string | null => { + if (!iconUrl) return null; + + try { + const parsed = new URL(iconUrl); + parsed.searchParams.set("v", version); + return parsed.toString(); + } catch { + // If URL parsing fails, keep original value instead of dropping marker icons. + return iconUrl; + } +}; + +const mapApiPoints = (data: AttractionResponse[], iconVersion: string): MapPoint[] => data .map((item) => ({ id: item.id, @@ -84,7 +100,7 @@ const mapApiPoints = (data: AttractionResponse[]): MapPoint[] => position: [item.latitude, item.longitude] as [number, number], priority: item.map_priority ?? 0, typeId: normalizeTypeId(item.location_type_id), - iconUrl: item.marker_icon, + iconUrl: addAssetVersionParam(item.marker_icon, iconVersion), mapColor: item.map_color, website: item.website, typeName: item.location_type_name, @@ -225,7 +241,7 @@ const TopAttractions = () => { } const payload: AttractionsApiResponse = await response.json(); - const mappedPoints = mapApiPoints(payload.data); + const mappedPoints = mapApiPoints(payload.data, String(Date.now())); setAllPoints(mappedPoints); setCachedAttractions(mappedPoints); @@ -277,10 +293,11 @@ const TopAttractions = () => { const cached = getCachedAttractions(); if (cached) { setAllPoints(cached); - } else { - void fetchAttractions(); } + // Always revalidate in the background so updated marker assets are picked up. + void fetchAttractions(); + refreshIntervalRef.current = window.setInterval(() => { void fetchAttractions(); }, CACHE_TTL_MS);