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
4 changes: 4 additions & 0 deletions backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ 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 locationsRouter from "./src/routes/public/locations";
import threadsRouter from "./src/routes/authenticated/forums/threads";
import repliesRouter from "./src/routes/authenticated/forums/replies";

Expand All @@ -30,6 +32,8 @@ app.use("/auth", authRouter);
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);

Expand Down
208 changes: 208 additions & 0 deletions backend/src/routes/public/attractions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
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 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,
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 clusterOverride = parseBooleanParam(req.query.cluster);

const bounds = normalizeBounds(minLat, maxLat, minLng, maxLng);
const isClusterMode = clusterOverride ?? zoom < 14;
const limit = isClusterMode
? parseLimit(req.query.limit, 80, 200)
: parseLimit(req.query.limit, 2000, 10000);

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<number> = [];

if (isClusterMode) {
const cellSize = getCellSizeByZoom(zoom);
dataQuery = `
WITH base AS (
SELECT
l.id,
l.name,
l.description,
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,
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 map_priority ASC NULLS LAST, name ASC NULLS LAST, id
) AS row_rank
FROM base
)
SELECT
id,
name,
description,
latitude,
longitude,
website,
map_priority,
location_type_id,
location_type_name,
marker_icon,
map_color
FROM ranked
WHERE row_rank = 1
ORDER BY map_priority ASC NULLS LAST, 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.map_priority,
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 l.map_priority DESC NULLS LAST, 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;
30 changes: 30 additions & 0 deletions backend/src/routes/public/locations.ts
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 11 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ 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";

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";
Expand All @@ -22,10 +22,13 @@ import {
resolvePathLocaleSegment,
} from "./i18n/locales";
import { localizePath, parseLocaleFromPathname } from "./utils/localeRouting";
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\/?$/,
Expand Down Expand Up @@ -157,10 +160,14 @@ function App() {
<Route index element={<Home />} />
<Route path="events" element={<Events />} />
<Route path="events/:id" element={<Events />} />
<Route path="neighbourhoods" element={<Neighbourhoods />} />

<Route path="explore" element={<Outlet />}>
<Route path="top-attractions" element={<TopAttractions />} />
<Route path="neighbourhoods" element={<Neighbourhoods />} />
</Route>
<Route path="auth" element={<Auth />} />

<Route path="travel-advice">
<Route path="travel-advice" element={<Outlet />}>
<Route
path="pre-travel-checklist"
element={<PreTravelChecklist />}
Expand Down
Loading
Loading