(null);
-
- function locate() {
- if (!navigator.geolocation) {
- setError("Geolocation is not available in this browser.");
- return;
- }
- setBusy(true);
- setError(null);
- navigator.geolocation.getCurrentPosition(
- (pos) => {
- setBusy(false);
- onLocated({ lat: pos.coords.latitude, lng: pos.coords.longitude });
- },
- (err) => {
- setBusy(false);
- setError(
- err.code === err.PERMISSION_DENIED
- ? "Allow location access in your browser settings."
- : err.code === err.TIMEOUT
- ? "Location request timed out. Try again."
- : "Your location could not be determined. Try again.",
- );
- },
- { enableHighAccuracy: true, timeout: 10000 },
- );
- }
+ const { busy, error, locate } = useNearMe(onLocated);
return (
diff --git a/src/components/map/near-me-fab.tsx b/src/components/map/near-me-fab.tsx
index 3dc9969..76cb4d2 100644
--- a/src/components/map/near-me-fab.tsx
+++ b/src/components/map/near-me-fab.tsx
@@ -4,6 +4,7 @@ import * as React from "react";
import { LocateFixed, Loader2 } from "lucide-react";
import { toast } from "sonner";
+import { useNearMe } from "@/components/map/use-near-me";
import type { LatLng } from "@/lib/geo";
import { cn } from "@/lib/utils";
@@ -14,37 +15,12 @@ interface NearMeFabProps {
/** Round floating "near me" button for the mobile map, sized for thumb reach. */
export function NearMeFab({ onLocated, className }: NearMeFabProps) {
- const [busy, setBusy] = React.useState(false);
- const [error, setError] = React.useState(null);
+ const { busy, error, locate } = useNearMe(onLocated);
- function locate() {
- if (!navigator.geolocation) {
- const message = "Geolocation is not available in this browser.";
- setError(message);
- toast.error(message);
- return;
- }
- setBusy(true);
- setError(null);
- navigator.geolocation.getCurrentPosition(
- (pos) => {
- setBusy(false);
- onLocated({ lat: pos.coords.latitude, lng: pos.coords.longitude });
- },
- (err) => {
- setBusy(false);
- const message =
- err.code === err.PERMISSION_DENIED
- ? "Allow location access in your browser settings."
- : err.code === err.TIMEOUT
- ? "Location request timed out. Try again."
- : "Your location could not be determined. Try again.";
- setError(message);
- toast.error(message);
- },
- { enableHighAccuracy: true, timeout: 10000 },
- );
- }
+ // The floating button has no room for an inline message, so it also toasts.
+ React.useEffect(() => {
+ if (error) toast.error(error);
+ }, [error]);
return (
<>
diff --git a/src/components/map/use-near-me.test.tsx b/src/components/map/use-near-me.test.tsx
new file mode 100644
index 0000000..9d3c508
--- /dev/null
+++ b/src/components/map/use-near-me.test.tsx
@@ -0,0 +1,198 @@
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ renderHook,
+ screen,
+} from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { NearMeButton } from "@/components/map/near-me-button";
+import { NearMeFab } from "@/components/map/near-me-fab";
+import {
+ GEOLOCATION_UNAVAILABLE_MESSAGE,
+ geolocationErrorMessage,
+ useNearMe,
+} from "@/components/map/use-near-me";
+
+const { toastError } = vi.hoisted(() => ({ toastError: vi.fn() }));
+vi.mock("sonner", () => ({ toast: { error: toastError } }));
+
+type Success = (position: GeolocationPosition) => void;
+type Failure = (error: GeolocationPositionError) => void;
+
+/** A stand-in for navigator.geolocation that lets each test answer the request. */
+function mockGeolocation() {
+ const calls: { success: Success; failure: Failure; options?: PositionOptions }[] = [];
+ Object.defineProperty(navigator, "geolocation", {
+ configurable: true,
+ value: {
+ getCurrentPosition: (
+ success: Success,
+ failure: Failure,
+ options?: PositionOptions,
+ ) => calls.push({ success, failure, options }),
+ },
+ });
+ return calls;
+}
+
+const position = (lat: number, lng: number) =>
+ ({ coords: { latitude: lat, longitude: lng } }) as GeolocationPosition;
+
+const positionError = (code: number) =>
+ ({
+ code,
+ PERMISSION_DENIED: 1,
+ POSITION_UNAVAILABLE: 2,
+ TIMEOUT: 3,
+ }) as GeolocationPositionError;
+
+beforeEach(() => toastError.mockReset());
+
+afterEach(() => {
+ cleanup();
+ Reflect.deleteProperty(navigator, "geolocation");
+});
+
+describe("geolocationErrorMessage", () => {
+ it("tells people to allow access when permission is denied", () => {
+ expect(geolocationErrorMessage(positionError(1))).toBe(
+ "Allow location access in your browser settings.",
+ );
+ });
+
+ it("says when the request timed out", () => {
+ expect(geolocationErrorMessage(positionError(3))).toBe(
+ "Location request timed out. Try again.",
+ );
+ });
+
+ it("falls back to a general message for anything else", () => {
+ expect(geolocationErrorMessage(positionError(2))).toBe(
+ "Your location could not be determined. Try again.",
+ );
+ expect(geolocationErrorMessage(positionError(99))).toBe(
+ "Your location could not be determined. Try again.",
+ );
+ });
+});
+
+describe("useNearMe", () => {
+ it("starts idle", () => {
+ mockGeolocation();
+ const { result } = renderHook(() => useNearMe(vi.fn()));
+ expect(result.current.busy).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("asks for a high-accuracy position with a 10 second timeout and is busy until it answers", () => {
+ const calls = mockGeolocation();
+ const { result } = renderHook(() => useNearMe(vi.fn()));
+ act(() => result.current.locate());
+ expect(calls).toHaveLength(1);
+ expect(calls[0]!.options).toEqual({ enableHighAccuracy: true, timeout: 10000 });
+ expect(result.current.busy).toBe(true);
+ });
+
+ it("passes the position on as lat and lng and stops being busy", () => {
+ const calls = mockGeolocation();
+ const onLocated = vi.fn();
+ const { result } = renderHook(() => useNearMe(onLocated));
+ act(() => result.current.locate());
+ act(() => calls[0]!.success(position(19.07, 72.87)));
+ expect(onLocated).toHaveBeenCalledWith({ lat: 19.07, lng: 72.87 });
+ expect(result.current.busy).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("turns a failure into a message and stops being busy", () => {
+ const calls = mockGeolocation();
+ const onLocated = vi.fn();
+ const { result } = renderHook(() => useNearMe(onLocated));
+ act(() => result.current.locate());
+ act(() => calls[0]!.failure(positionError(1)));
+ expect(onLocated).not.toHaveBeenCalled();
+ expect(result.current.busy).toBe(false);
+ expect(result.current.error).toBe("Allow location access in your browser settings.");
+ });
+
+ it("clears the old error when it tries again", () => {
+ const calls = mockGeolocation();
+ const { result } = renderHook(() => useNearMe(vi.fn()));
+ act(() => result.current.locate());
+ act(() => calls[0]!.failure(positionError(3)));
+ expect(result.current.error).toBe("Location request timed out. Try again.");
+ act(() => result.current.locate());
+ expect(result.current.error).toBeNull();
+ expect(result.current.busy).toBe(true);
+ });
+
+ it("reports a browser with no geolocation without asking for anything", () => {
+ const { result } = renderHook(() => useNearMe(vi.fn()));
+ act(() => result.current.locate());
+ expect(result.current.error).toBe(GEOLOCATION_UNAVAILABLE_MESSAGE);
+ expect(result.current.busy).toBe(false);
+ });
+});
+
+describe("the two Near me triggers share the hook", () => {
+ it("NearMeButton shows Locating..., then the message inline", () => {
+ const calls = mockGeolocation();
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "Near me" }));
+ expect(screen.getByRole("button", { name: "Locating..." })).toHaveProperty(
+ "disabled",
+ true,
+ );
+ act(() => calls[0]!.failure(positionError(3)));
+ expect(screen.getByRole("alert").textContent).toBe(
+ "Location request timed out. Try again.",
+ );
+ expect(screen.getByRole("button", { name: "Near me" })).toHaveProperty(
+ "disabled",
+ false,
+ );
+ });
+
+ it("NearMeFab announces the message to screen readers and toasts it", () => {
+ const calls = mockGeolocation();
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "Find places near me" }));
+ act(() => calls[0]!.failure(positionError(1)));
+ const message = "Allow location access in your browser settings.";
+ expect(screen.getByRole("alert").textContent).toBe(message);
+ expect(screen.getByRole("alert").className).toContain("sr-only");
+ expect(toastError).toHaveBeenCalledWith(message);
+ });
+
+ it("NearMeFab toasts again when the same error happens a second time", () => {
+ const calls = mockGeolocation();
+ render();
+ const button = screen.getByRole("button", { name: "Find places near me" });
+ fireEvent.click(button);
+ act(() => calls[0]!.failure(positionError(3)));
+ fireEvent.click(button);
+ act(() => calls[1]!.failure(positionError(3)));
+ expect(toastError).toHaveBeenCalledTimes(2);
+ });
+
+ it("both pass the located position to their callback", () => {
+ const calls = mockGeolocation();
+ const first = vi.fn();
+ const second = vi.fn();
+ render(
+ <>
+
+
+ >,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Near me" }));
+ fireEvent.click(screen.getByRole("button", { name: "Find places near me" }));
+ act(() => calls[0]!.success(position(1, 2)));
+ act(() => calls[1]!.success(position(3, 4)));
+ expect(first).toHaveBeenCalledWith({ lat: 1, lng: 2 });
+ expect(second).toHaveBeenCalledWith({ lat: 3, lng: 4 });
+ });
+});
diff --git a/src/components/map/use-near-me.ts b/src/components/map/use-near-me.ts
new file mode 100644
index 0000000..9dfdadf
--- /dev/null
+++ b/src/components/map/use-near-me.ts
@@ -0,0 +1,56 @@
+"use client";
+
+import * as React from "react";
+
+import type { LatLng } from "@/lib/geo";
+
+/** The message shown when the browser has no Geolocation API at all. */
+export const GEOLOCATION_UNAVAILABLE_MESSAGE =
+ "Geolocation is not available in this browser.";
+
+/** Maps a `GeolocationPositionError` to the message shown to the visitor. */
+export function geolocationErrorMessage(
+ error: Pick,
+): string {
+ if (error.code === error.PERMISSION_DENIED) {
+ return "Allow location access in your browser settings.";
+ }
+ if (error.code === error.TIMEOUT) {
+ return "Location request timed out. Try again.";
+ }
+ return "Your location could not be determined. Try again.";
+}
+
+/**
+ * The geolocation logic behind both "Near me" triggers (the desktop button and
+ * the mobile floating button): asks the browser for the position once, tracks
+ * `busy` while it waits, and turns a failure into a readable `error`. A
+ * component calls `locate()` from its own click handler and decides how to show
+ * `busy` and `error`.
+ */
+export function useNearMe(onLocated: (location: LatLng) => void) {
+ const [busy, setBusy] = React.useState(false);
+ const [error, setError] = React.useState(null);
+
+ const locate = React.useCallback(() => {
+ if (!navigator.geolocation) {
+ setError(GEOLOCATION_UNAVAILABLE_MESSAGE);
+ return;
+ }
+ setBusy(true);
+ setError(null);
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ setBusy(false);
+ onLocated({ lat: pos.coords.latitude, lng: pos.coords.longitude });
+ },
+ (err) => {
+ setBusy(false);
+ setError(geolocationErrorMessage(err));
+ },
+ { enableHighAccuracy: true, timeout: 10000 },
+ );
+ }, [onLocated]);
+
+ return { busy, error, locate };
+}
diff --git a/src/lib/competitions-config.test.ts b/src/lib/competitions-config.test.ts
new file mode 100644
index 0000000..b95238e
--- /dev/null
+++ b/src/lib/competitions-config.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { getCategories, getCompetitions } from "@/lib/competitions";
+import studyMapConfig from "../../studymap.config";
+import example from "../../studymap.config.example";
+import type { Competition } from "@/lib/types";
+
+const custom = vi.hoisted(() => ({ competitions: [] as unknown[] }));
+
+vi.mock("../../studymap.config", async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ default: {
+ ...original.default,
+ get competitions() {
+ return custom.competitions.length
+ ? custom.competitions
+ : original.default.competitions;
+ },
+ },
+ };
+});
+
+function competition(id: string, category: Competition["category"]): Competition {
+ return {
+ id,
+ name: id,
+ organizer: "Test Org",
+ organizer_url: "https://example.com",
+ category,
+ subjects: [],
+ description: "A test competition.",
+ format: "online",
+ age_min: 13,
+ age_max: 18,
+ participation: "individual",
+ region: "international",
+ fee: { amount: 0, currency: "USD" },
+ prize: "A prize",
+ official_url: "https://example.com",
+ cycle_year: 2026,
+ dates: [],
+ added_by: "test",
+ };
+}
+
+describe("competitions come from studymap.config.ts", () => {
+ it("getCompetitions() returns the config's competitions, as getPlaces() returns its places", () => {
+ expect(getCompetitions()).toBe(studyMapConfig.competitions);
+ expect(getCompetitions().length).toBeGreaterThan(0);
+ });
+
+ it("a fork that sets its own competitions gets exactly those", () => {
+ custom.competitions = [competition("only-one", "coding")];
+ try {
+ expect(getCompetitions().map((item) => item.id)).toEqual(["only-one"]);
+ expect(getCategories()).toEqual(["coding"]);
+ } finally {
+ custom.competitions = [];
+ }
+ });
+
+ it("the example config has an empty competitions list, so it type-checks as a StudyMapConfig", () => {
+ expect(example.competitions).toEqual([]);
+ });
+});
diff --git a/src/lib/competitions.ts b/src/lib/competitions.ts
index a281578..59f1506 100644
--- a/src/lib/competitions.ts
+++ b/src/lib/competitions.ts
@@ -33,43 +33,11 @@ export function formatCompetitionDate(iso: string): string {
return `${MONTH_NAMES[month - 1]} ${day}, ${year}`;
}
-import artsDesign from "../../data/competitions/arts_design.json";
-import business from "../../data/competitions/business.json";
-import coding from "../../data/competitions/coding.json";
-import creativeWriting from "../../data/competitions/creative_writing.json";
-import debateMun from "../../data/competitions/debate_mun.json";
-import essayWriting from "../../data/competitions/essay_writing.json";
-import filmVideo from "../../data/competitions/film_video.json";
-import finance from "../../data/competitions/finance.json";
-import history from "../../data/competitions/history.json";
-import humanities from "../../data/competitions/humanities.json";
-import interdisciplinary from "../../data/competitions/interdisciplinary.json";
-import mathematics from "../../data/competitions/mathematics.json";
-import research from "../../data/competitions/research.json";
-import scholarship from "../../data/competitions/scholarship.json";
-import stem from "../../data/competitions/stem.json";
+import studyMapConfig from "../../studymap.config";
-const ALL_COMPETITIONS: Competition[] = [
- ...(artsDesign as Competition[]),
- ...(business as Competition[]),
- ...(coding as Competition[]),
- ...(creativeWriting as Competition[]),
- ...(debateMun as Competition[]),
- ...(essayWriting as Competition[]),
- ...(filmVideo as Competition[]),
- ...(finance as Competition[]),
- ...(history as Competition[]),
- ...(humanities as Competition[]),
- ...(interdisciplinary as Competition[]),
- ...(mathematics as Competition[]),
- ...(research as Competition[]),
- ...(scholarship as Competition[]),
- ...(stem as Competition[]),
-];
-
-/** Every competition record, sourced from `data/competitions/.json`. */
+/** Every competition record, sourced from `studymap.config.ts` (by default `data/competitions/.json`). */
export function getCompetitions(): Competition[] {
- return ALL_COMPETITIONS;
+ return studyMapConfig.competitions;
}
export interface CompetitionFilters {
diff --git a/src/lib/docs-nav.test.ts b/src/lib/docs-nav.test.ts
new file mode 100644
index 0000000..10c090a
--- /dev/null
+++ b/src/lib/docs-nav.test.ts
@@ -0,0 +1,64 @@
+import { FileText } from "lucide-react";
+import { describe, expect, it } from "vitest";
+
+import {
+ DOCS_GROUPS,
+ docsPages,
+ groupDocsByTopic,
+ type DocsNavEntry,
+} from "@/lib/docs-nav";
+
+const entry = (href: string, group?: DocsNavEntry["group"]): DocsNavEntry => ({
+ href,
+ title: href,
+ description: "",
+ icon: FileText,
+ group,
+});
+
+describe("groupDocsByTopic", () => {
+ it("returns the groups in DOCS_GROUPS order, each with its own pages", () => {
+ const groups = groupDocsByTopic();
+ expect(groups.map(({ group }) => group)).toEqual(
+ DOCS_GROUPS.filter((group) => docsPages.some((page) => page.group === group)),
+ );
+ for (const { group, entries } of groups) {
+ expect(entries.length).toBeGreaterThan(0);
+ expect(entries.every((page) => page.group === group)).toBe(true);
+ }
+ });
+
+ it("places every docs page in exactly one group", () => {
+ const grouped = groupDocsByTopic().flatMap(({ entries }) => entries);
+ expect(grouped).toHaveLength(docsPages.length);
+ expect(new Set(grouped.map((page) => page.href)).size).toBe(docsPages.length);
+ });
+
+ it("leaves a group out when it has no pages", () => {
+ const groups = groupDocsByTopic([
+ entry("/docs/a", "Guides"),
+ entry("/docs/b", "Developers"),
+ ]);
+ expect(groups.map(({ group }) => group)).toEqual(["Guides", "Developers"]);
+ });
+
+ it("keeps the order of pages within a group", () => {
+ const groups = groupDocsByTopic([
+ entry("/docs/second", "Guides"),
+ entry("/docs/other", "Developers"),
+ entry("/docs/first", "Guides"),
+ ]);
+ expect(groups[0]!.entries.map((page) => page.href)).toEqual([
+ "/docs/second",
+ "/docs/first",
+ ]);
+ });
+
+ it("drops a page that has no group", () => {
+ expect(groupDocsByTopic([entry("/docs/loose")])).toEqual([]);
+ });
+
+ it("returns nothing for no pages", () => {
+ expect(groupDocsByTopic([])).toEqual([]);
+ });
+});
diff --git a/src/lib/docs-nav.ts b/src/lib/docs-nav.ts
index 428da86..bbbbbcb 100644
--- a/src/lib/docs-nav.ts
+++ b/src/lib/docs-nav.ts
@@ -233,6 +233,20 @@ export const docsNav: DocsNavEntry[] = [
/** Same entries, index page excluded — the order sidebar links, groups, and the pager all walk. */
export const docsPages = docsNav.filter((entry) => entry.href !== "/docs");
+/**
+ * The docs pages grouped by topic, in `DOCS_GROUPS` order, leaving out any
+ * group with no pages. Both /docs index views (grid and list) render from
+ * this, so they always agree on which groups show and in what order.
+ */
+export function groupDocsByTopic(
+ pages: DocsNavEntry[] = docsPages,
+): { group: DocsGroup; entries: DocsNavEntry[] }[] {
+ return DOCS_GROUPS.map((group) => ({
+ group,
+ entries: pages.filter((entry) => entry.group === group),
+ })).filter(({ entries }) => entries.length > 0);
+}
+
export function getDocsNavEntry(pathname: string) {
return docsNav.find((entry) => entry.href === pathname);
}
diff --git a/studymap.config.example.ts b/studymap.config.example.ts
index fc60714..49381f3 100644
--- a/studymap.config.example.ts
+++ b/studymap.config.example.ts
@@ -7,6 +7,8 @@
* with your own data, or start adding real files under data/places/
* per CONTRIBUTING.md and import those instead.
* 3. Set center / defaultZoom / cities to your region.
+ * 4. Optional: list your own competitions under `competitions` (see
+ * data/competitions/CONTRIBUTING.md), or leave it empty.
*/
import type { City, Place } from "@/lib/types";
import type { StudyMapConfig } from "./studymap.config";
@@ -21,6 +23,9 @@ const studyMapConfig: StudyMapConfig = {
defaultZoom: 12,
cities,
places: [...(library as Place[]), ...(bookShop as Place[])],
+ // No sample competitions ship with the example. The competitions pages
+ // show an empty catalog until you add some.
+ competitions: [],
};
export default studyMapConfig;
diff --git a/studymap.config.ts b/studymap.config.ts
index 8e99c1e..44e3b67 100644
--- a/studymap.config.ts
+++ b/studymap.config.ts
@@ -3,10 +3,11 @@
*
* Forking StudyMap for a different city or dataset? This is the one file to
* change. Swap the imports below for your own `data/places/*.json`, and
- * adjust `center` / `defaultZoom` / `cities` to your region. No other file
- * needs to change; `src/lib/places.ts` reads from here.
+ * adjust `center` / `defaultZoom` / `cities` to your region, and do the same
+ * for the competitions below (or leave `competitions` empty). No other file
+ * needs to change; `src/lib/places.ts` and `src/lib/competitions.ts` read from here.
*/
-import type { City, Place } from "@/lib/types";
+import type { City, Competition, Place } from "@/lib/types";
import airport from "./data/places/airport.json";
import library from "./data/places/library.json";
@@ -15,6 +16,22 @@ import satCentre from "./data/places/sat_centre.json";
import foreignLangExamCentre from "./data/places/foreign_lang_exam_centre.json";
import govOffices from "./data/places/gov_offices.json";
+import artsDesignCompetitions from "./data/competitions/arts_design.json";
+import businessCompetitions from "./data/competitions/business.json";
+import codingCompetitions from "./data/competitions/coding.json";
+import creativeWritingCompetitions from "./data/competitions/creative_writing.json";
+import debateMunCompetitions from "./data/competitions/debate_mun.json";
+import essayWritingCompetitions from "./data/competitions/essay_writing.json";
+import filmVideoCompetitions from "./data/competitions/film_video.json";
+import financeCompetitions from "./data/competitions/finance.json";
+import historyCompetitions from "./data/competitions/history.json";
+import humanitiesCompetitions from "./data/competitions/humanities.json";
+import interdisciplinaryCompetitions from "./data/competitions/interdisciplinary.json";
+import mathematicsCompetitions from "./data/competitions/mathematics.json";
+import researchCompetitions from "./data/competitions/research.json";
+import scholarshipCompetitions from "./data/competitions/scholarship.json";
+import stemCompetitions from "./data/competitions/stem.json";
+
export interface StudyMapConfig {
/** Initial map center, as [lat, lng]. */
center: [number, number];
@@ -29,6 +46,12 @@ export interface StudyMapConfig {
cities: City[];
/** Every place pin StudyMap renders, merged from the data sources above. */
places: Place[];
+ /**
+ * Every competition StudyMap lists, merged from the data sources above
+ * (`data/competitions/.json`). Use an empty array if your
+ * fork doesn't have a competitions catalog.
+ */
+ competitions: Competition[];
}
const studyMapConfig: StudyMapConfig = {
@@ -43,6 +66,23 @@ const studyMapConfig: StudyMapConfig = {
...(foreignLangExamCentre as Place[]),
...(govOffices as Place[]),
],
+ competitions: [
+ ...(artsDesignCompetitions as Competition[]),
+ ...(businessCompetitions as Competition[]),
+ ...(codingCompetitions as Competition[]),
+ ...(creativeWritingCompetitions as Competition[]),
+ ...(debateMunCompetitions as Competition[]),
+ ...(essayWritingCompetitions as Competition[]),
+ ...(filmVideoCompetitions as Competition[]),
+ ...(financeCompetitions as Competition[]),
+ ...(historyCompetitions as Competition[]),
+ ...(humanitiesCompetitions as Competition[]),
+ ...(interdisciplinaryCompetitions as Competition[]),
+ ...(mathematicsCompetitions as Competition[]),
+ ...(researchCompetitions as Competition[]),
+ ...(scholarshipCompetitions as Competition[]),
+ ...(stemCompetitions as Competition[]),
+ ],
};
export default studyMapConfig;