├── .github └── FUNDING.yml ├── public └── showcase │ ├── 01.png │ ├── 02.png │ ├── 03.png │ └── mobile.png ├── postcss.config.mjs ├── next.config.ts ├── src ├── lib │ ├── utils.ts │ └── mapbox │ │ ├── constants.ts │ │ ├── provider.tsx │ │ ├── utils.tsx │ │ └── api.ts ├── components │ ├── theme-provider.tsx │ ├── ui │ │ ├── separator.tsx │ │ ├── input.tsx │ │ ├── badge.tsx │ │ ├── popover.tsx │ │ ├── tabs.tsx │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ └── command.tsx │ ├── map │ │ ├── map-controls.tsx │ │ ├── map-popup.tsx │ │ ├── map-styles.tsx │ │ ├── map-marker.tsx │ │ └── map-search.tsx │ ├── location-marker.tsx │ └── location-popup.tsx ├── hooks │ └── useDebounce.ts ├── context │ └── map-context.ts └── app │ ├── page.tsx │ ├── layout.tsx │ └── globals.css ├── components.json ├── .gitignore ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [AnmolSaini16] 2 | buy_me_a_coffee: anmoldeep_singh 3 | -------------------------------------------------------------------------------- /public/showcase/01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnmolSaini16/next-maps/HEAD/public/showcase/01.png -------------------------------------------------------------------------------- /public/showcase/02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnmolSaini16/next-maps/HEAD/public/showcase/02.png -------------------------------------------------------------------------------- /public/showcase/03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnmolSaini16/next-maps/HEAD/public/showcase/03.png -------------------------------------------------------------------------------- /public/showcase/mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnmolSaini16/next-maps/HEAD/public/showcase/mobile.png -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/mapbox/constants.ts: -------------------------------------------------------------------------------- 1 | export const MAP_CONSTANTS = { 2 | FLY_TO: { 3 | ZOOM: 14, 4 | SPEED: 4, 5 | DURATION: 1000, 6 | }, 7 | SEARCH: { 8 | DEBOUNCE_MS: 400, 9 | DEFAULT_LIMIT: 5, 10 | DEFAULT_COUNTRY: "US", 11 | DEFAULT_PROXIMITY: [-122.4194, 37.7749] as [number, number], // San Francisco 12 | }, 13 | } as const; 14 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: React.ComponentProps) { 10 | return {children}; 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useDebounce(value: T, delay: number): T { 4 | const [debounced, setDebounced] = useState(value); 5 | 6 | useEffect(() => { 7 | const handler = setTimeout(() => setDebounced(value), delay); 8 | return () => clearTimeout(handler); 9 | }, [value, delay]); 10 | 11 | return debounced; 12 | } 13 | -------------------------------------------------------------------------------- /src/context/map-context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | interface MapContextType { 4 | map: mapboxgl.Map; 5 | } 6 | 7 | export const MapContext = createContext(null); 8 | 9 | export function useMap() { 10 | const context = useContext(MapContext); 11 | if (!context) { 12 | throw new Error("useMap must be used within a MapProvider"); 13 | } 14 | return context; 15 | } 16 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/globals.css", 9 | "baseColor": "gray", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Separator({ 9 | className, 10 | orientation = "horizontal", 11 | decorative = true, 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 25 | ) 26 | } 27 | 28 | export { Separator } 29 | -------------------------------------------------------------------------------- /src/components/map/map-controls.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { PlusIcon, MinusIcon } from "lucide-react"; 3 | 4 | import { useMap } from "@/context/map-context"; 5 | import { Button } from "../ui/button"; 6 | 7 | export default function MapCotrols() { 8 | const { map } = useMap(); 9 | 10 | const zoomIn = () => { 11 | map?.zoomIn(); 12 | }; 13 | 14 | const zoomOut = () => { 15 | map?.zoomOut(); 16 | }; 17 | 18 | return ( 19 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRef } from "react"; 4 | 5 | import MapProvider from "@/lib/mapbox/provider"; 6 | import MapStyles from "@/components/map/map-styles"; 7 | import MapCotrols from "@/components/map/map-controls"; 8 | import MapSearch from "@/components/map/map-search"; 9 | 10 | export default function Home() { 11 | const mapContainerRef = useRef(null); 12 | 13 | return ( 14 |
15 |
20 | 21 | 29 | 30 | 31 | 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/location-marker.tsx: -------------------------------------------------------------------------------- 1 | import { MapPin } from "lucide-react"; 2 | 3 | import { LocationFeature } from "@/lib/mapbox/utils"; 4 | import Marker from "./map/map-marker"; 5 | 6 | interface LocationMarkerProps { 7 | location: LocationFeature; 8 | onHover: (data: LocationFeature | null) => void; 9 | } 10 | 11 | export function LocationMarker({ location, onHover }: LocationMarkerProps) { 12 | return ( 13 | { 18 | if (isHovered) { 19 | onHover(data); 20 | } else { 21 | onHover(null); 22 | } 23 | }} 24 | > 25 |
26 | 27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | import { ThemeProvider } from "next-themes"; 5 | 6 | const geistSans = Geist({ 7 | variable: "--font-geist-sans", 8 | subsets: ["latin"], 9 | }); 10 | 11 | const geistMono = Geist_Mono({ 12 | variable: "--font-geist-mono", 13 | subsets: ["latin"], 14 | }); 15 | 16 | export const metadata: Metadata = { 17 | title: "Next Maps", 18 | description: "Next Maps", 19 | }; 20 | 21 | export default function RootLayout({ 22 | children, 23 | }: Readonly<{ 24 | children: React.ReactNode; 25 | }>) { 26 | return ( 27 | 28 | 31 | 37 |
{children}
38 |
39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Anmoldeep Singh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-maps", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-dialog": "^1.1.6", 13 | "@radix-ui/react-popover": "^1.1.6", 14 | "@radix-ui/react-separator": "^1.1.2", 15 | "@radix-ui/react-slot": "^1.1.2", 16 | "@radix-ui/react-tabs": "^1.1.3", 17 | "class-variance-authority": "^0.7.1", 18 | "clsx": "^2.1.1", 19 | "cmdk": "^1.1.1", 20 | "lucide-react": "^0.483.0", 21 | "mapbox-gl": "^3.10.0", 22 | "next": "15.2.3", 23 | "next-themes": "^0.4.6", 24 | "react": "^19.0.0", 25 | "react-dom": "^19.0.0", 26 | "tailwind-merge": "^3.0.2", 27 | "tw-animate-css": "^1.2.4" 28 | }, 29 | "devDependencies": { 30 | "@tailwindcss/postcss": "^4", 31 | "@types/mapbox__mapbox-gl-geocoder": "^5.0.0", 32 | "@types/node": "^20", 33 | "@types/react": "^19", 34 | "@types/react-dom": "^19", 35 | "tailwindcss": "^4", 36 | "typescript": "^5" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Popover({ 9 | ...props 10 | }: React.ComponentProps) { 11 | return 12 | } 13 | 14 | function PopoverTrigger({ 15 | ...props 16 | }: React.ComponentProps) { 17 | return 18 | } 19 | 20 | function PopoverContent({ 21 | className, 22 | align = "center", 23 | sideOffset = 4, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 28 | 38 | 39 | ) 40 | } 41 | 42 | function PopoverAnchor({ 43 | ...props 44 | }: React.ComponentProps) { 45 | return 46 | } 47 | 48 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 49 | -------------------------------------------------------------------------------- /src/components/map/map-popup.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMap } from "@/context/map-context"; 4 | import { cn } from "@/lib/utils"; 5 | import mapboxgl from "mapbox-gl"; 6 | import { useCallback, useEffect, useMemo } from "react"; 7 | import { createPortal } from "react-dom"; 8 | 9 | type PopupProps = { 10 | children: React.ReactNode; 11 | latitude?: number; 12 | longitude?: number; 13 | onClose?: () => void; 14 | marker?: mapboxgl.Marker; 15 | } & mapboxgl.PopupOptions; 16 | 17 | export default function Popup({ 18 | latitude, 19 | longitude, 20 | children, 21 | marker, 22 | onClose, 23 | className, 24 | ...props 25 | }: PopupProps) { 26 | const { map } = useMap(); 27 | 28 | const container = useMemo(() => { 29 | return document.createElement("div"); 30 | }, []); 31 | 32 | const handleClose = useCallback(() => { 33 | onClose?.(); 34 | }, [onClose]); 35 | 36 | useEffect(() => { 37 | if (!map) return; 38 | 39 | const popupOptions: mapboxgl.PopupOptions = { 40 | ...props, 41 | className: cn("mapboxgl-custom-popup", className), 42 | }; 43 | 44 | const popup = new mapboxgl.Popup(popupOptions) 45 | .setDOMContent(container) 46 | .setMaxWidth("none"); 47 | 48 | popup.on("close", handleClose); 49 | 50 | if (marker) { 51 | const currentPopup = marker.getPopup(); 52 | if (currentPopup) { 53 | currentPopup.remove(); 54 | } 55 | marker.setPopup(popup); 56 | marker.togglePopup(); 57 | } else if (latitude !== undefined && longitude !== undefined) { 58 | popup.setLngLat([longitude, latitude]).addTo(map); 59 | } 60 | 61 | return () => { 62 | popup.off("close", handleClose); 63 | if (popup.isOpen()) { 64 | popup.remove(); 65 | } 66 | 67 | if (marker && marker.getPopup()) { 68 | marker.setPopup(null); 69 | } 70 | }; 71 | }, [ 72 | map, 73 | marker, 74 | latitude, 75 | longitude, 76 | props, 77 | className, 78 | container, 79 | handleClose, 80 | ]); 81 | 82 | return createPortal(children, container); 83 | } 84 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Tabs({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 18 | ) 19 | } 20 | 21 | function TabsList({ 22 | className, 23 | ...props 24 | }: React.ComponentProps) { 25 | return ( 26 | 34 | ) 35 | } 36 | 37 | function TabsTrigger({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ) 51 | } 52 | 53 | function TabsContent({ 54 | className, 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 63 | ) 64 | } 65 | 66 | export { Tabs, TabsList, TabsTrigger, TabsContent } 67 | -------------------------------------------------------------------------------- /src/lib/mapbox/provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect, useRef, useState } from "react"; 4 | import mapboxgl from "mapbox-gl"; 5 | import "mapbox-gl/dist/mapbox-gl.css"; 6 | 7 | import { MapContext } from "@/context/map-context"; 8 | import { Loader } from "lucide-react"; 9 | 10 | mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN!; 11 | 12 | type MapComponentProps = { 13 | mapContainerRef: React.RefObject; 14 | initialViewState: { 15 | longitude: number; 16 | latitude: number; 17 | zoom: number; 18 | }; 19 | children?: React.ReactNode; 20 | }; 21 | 22 | export default function MapProvider({ 23 | mapContainerRef, 24 | initialViewState, 25 | children, 26 | }: MapComponentProps) { 27 | const mapRef = useRef(null); 28 | const [loaded, setLoaded] = useState(false); 29 | 30 | useEffect(() => { 31 | if (!mapContainerRef.current) return; 32 | 33 | if (mapRef.current) { 34 | mapRef.current.remove(); 35 | mapRef.current = null; 36 | } 37 | 38 | const mapInstance = new mapboxgl.Map({ 39 | container: mapContainerRef.current, 40 | style: "mapbox://styles/mapbox/standard", 41 | center: [initialViewState.longitude, initialViewState.latitude], 42 | zoom: initialViewState.zoom, 43 | attributionControl: false, 44 | logoPosition: "bottom-right", 45 | }); 46 | 47 | mapRef.current = mapInstance; 48 | 49 | const onLoad = () => setLoaded(true); 50 | 51 | mapInstance.on("load", onLoad); 52 | 53 | return () => { 54 | mapInstance.off("load", onLoad); 55 | mapInstance.remove(); 56 | mapRef.current = null; 57 | }; 58 | }, [initialViewState]); 59 | 60 | return ( 61 |
62 | 63 | {children} 64 | 65 | {!loaded && ( 66 |
67 |
68 | 69 | Loading map... 70 |
71 |
72 | )} 73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🗺️ Modern Map in Next.js with Mapbox 2 | 3 | #### Lightweight, clean and beautifully-designed map with Next.js, Mapbox, shadcn ui, and Tailwind. Customizable and extensible component design. 4 | 5 | https://github.com/user-attachments/assets/9caa968c-bfe1-44bf-a1dc-b375070a3e93 6 | 7 |
8 | Map Application Screenshot 1 9 |

10 | Map Application Screenshot 2 11 |

12 | Map Application Screenshot 3 13 |

14 | Map Application Screenshot 4 15 |
16 | 17 | ## ⚡ Stack 18 | 19 | - Next.js (App Router) 20 | - Tailwind CSS 21 | - shadcn/ui 22 | - Mapbox GL JS 23 | - Mapbox Searchbox API 24 | 25 | ## 🔍 Features 26 | 27 | - 📍 Mapbox GL interactive map 28 | - 🔎 Mapbox Searchbox API 29 | - 🎨 Clean UI with shadcn/ui 30 | - ⚡ Responsive & fast 31 | - 📱 Mobile-friendly layout 32 | 33 | ## 🚀 Getting Started 34 | 35 | First, install dependencies and start the development server: 36 | 37 | ```sh 38 | git clone https://github.com/AnmolSaini16/next-maps.git 39 | cd your-repo 40 | npm install 41 | npm run dev 42 | ``` 43 | 44 | ### 🔐 Environment Variables 45 | 46 | ```sh 47 | NEXT_PUBLIC_MAPBOX_TOKEN=your_mapbox_access_token 48 | NEXT_PUBLIC_MAPBOX_SESSION_TOKEN=your_uuidv4_session_token 49 | ``` 50 | 51 | ## 📦 Deployment 52 | 53 | Deploy on [Vercel](https://vercel.com) in seconds. 54 | 55 | ## 📚 Docs 56 | 57 | - [Next.js Docs](https://nextjs.org/docs) 58 | - [Mapbox GL JS](https://docs.mapbox.com/mapbox-gl-js/guides) 59 | - [Tailwind CSS](https://tailwindcss.com/docs) 60 | - [shadcn/ui Docs](https://ui.shadcn.com/docs) 61 | 62 | ## 🤝 Contributing 63 | 64 | Contributions are welcome! 65 | If you’d like to improve this project, feel free to fork it and open a pull request. 66 | 67 | ## ☕ Support Me 68 | 69 | If you find this project useful, consider supporting me: 70 | 71 | [Sponsor on github](https://github.com/sponsors/AnmolSaini16) 72 | 73 | Buy Me A Coffee 74 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /src/components/map/map-styles.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect, useState } from "react"; 4 | import { 5 | MapIcon, 6 | MoonIcon, 7 | SatelliteIcon, 8 | SunIcon, 9 | TreesIcon, 10 | } from "lucide-react"; 11 | import { useTheme } from "next-themes"; 12 | 13 | import { useMap } from "@/context/map-context"; 14 | import { Tabs, TabsList, TabsTrigger } from "../ui/tabs"; 15 | 16 | type StyleOption = { 17 | id: string; 18 | label: string; 19 | icon: React.ReactNode; 20 | }; 21 | 22 | const STYLE_OPTIONS: StyleOption[] = [ 23 | { 24 | id: "streets-v12", 25 | label: "Map", 26 | icon: , 27 | }, 28 | { 29 | id: "satellite-streets-v12", 30 | label: "Satellite", 31 | icon: , 32 | }, 33 | { 34 | id: "outdoors-v12", 35 | label: "Terrain", 36 | icon: , 37 | }, 38 | 39 | { 40 | id: "light-v11", 41 | label: "Light", 42 | icon: , 43 | }, 44 | { 45 | id: "dark-v11", 46 | label: "Dark", 47 | icon: , 48 | }, 49 | ]; 50 | 51 | export default function MapStyles() { 52 | const { map } = useMap(); 53 | const { setTheme } = useTheme(); 54 | const [activeStyle, setActiveStyle] = useState("streets-v12"); 55 | 56 | const handleChange = (value: string) => { 57 | if (!map) return; 58 | map.setStyle(`mapbox://styles/mapbox/${value}`); 59 | setActiveStyle(value); 60 | }; 61 | 62 | useEffect(() => { 63 | if (activeStyle === "dark-v11") { 64 | setTheme("dark"); 65 | } else setTheme("light"); 66 | }, [map, activeStyle]); 67 | 68 | return ( 69 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/lib/mapbox/utils.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Coffee, 3 | Utensils, 4 | ShoppingBag, 5 | Hotel, 6 | Dumbbell, 7 | Landmark, 8 | Store, 9 | Banknote, 10 | GraduationCap, 11 | Shirt, 12 | Stethoscope, 13 | Home, 14 | } from "lucide-react"; 15 | 16 | export const iconMap: { [key: string]: React.ReactNode } = { 17 | café: , 18 | cafe: , 19 | coffee: , 20 | restaurant: , 21 | food: , 22 | hotel: , 23 | lodging: , 24 | gym: , 25 | bank: , 26 | shopping: , 27 | store: , 28 | government: , 29 | school: , 30 | hospital: , 31 | clothing: , 32 | home: , 33 | }; 34 | 35 | export type LocationSuggestion = { 36 | mapbox_id: string; 37 | name: string; 38 | place_formatted: string; 39 | maki?: string; 40 | }; 41 | 42 | export type LocationFeature = { 43 | type: "Feature"; 44 | geometry: { 45 | type: "Point"; 46 | coordinates: [number, number]; 47 | }; 48 | properties: { 49 | name: string; 50 | name_preferred?: string; 51 | mapbox_id: string; 52 | feature_type: string; 53 | address?: string; 54 | full_address?: string; 55 | place_formatted?: string; 56 | context: { 57 | country?: { 58 | name: string; 59 | country_code: string; 60 | country_code_alpha_3: string; 61 | }; 62 | region?: { 63 | name: string; 64 | region_code: string; 65 | region_code_full: string; 66 | }; 67 | postcode?: { name: string }; 68 | district?: { name: string }; 69 | place?: { name: string }; 70 | locality?: { name: string }; 71 | neighborhood?: { name: string }; 72 | address?: { 73 | name: string; 74 | address_number?: string; 75 | street_name?: string; 76 | }; 77 | street?: { name: string }; 78 | }; 79 | coordinates: { 80 | latitude: number; 81 | longitude: number; 82 | accuracy?: string; 83 | routable_points?: { 84 | name: string; 85 | latitude: number; 86 | longitude: number; 87 | note?: string; 88 | }[]; 89 | }; 90 | language?: string; 91 | maki?: string; 92 | poi_category?: string[]; 93 | poi_category_ids?: string[]; 94 | brand?: string[]; 95 | brand_id?: string[]; 96 | external_ids?: Record; 97 | metadata?: Record; 98 | bbox?: [number, number, number, number]; 99 | operational_status?: string; 100 | }; 101 | }; 102 | -------------------------------------------------------------------------------- /src/components/map/map-marker.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import mapboxgl, { MarkerOptions } from "mapbox-gl"; 4 | import React, { useEffect, useMemo, useRef } from "react"; 5 | import { createPortal } from "react-dom"; 6 | 7 | import { useMap } from "@/context/map-context"; 8 | import { LocationFeature } from "@/lib/mapbox/utils"; 9 | 10 | type Props = { 11 | longitude: number; 12 | latitude: number; 13 | data: LocationFeature; 14 | onHover?: ({ 15 | isHovered, 16 | position, 17 | marker, 18 | data, 19 | }: { 20 | isHovered: boolean; 21 | position: { longitude: number; latitude: number }; 22 | marker: mapboxgl.Marker; 23 | data: LocationFeature; 24 | }) => void; 25 | onClick?: ({ 26 | position, 27 | marker, 28 | data, 29 | }: { 30 | position: { longitude: number; latitude: number }; 31 | marker: mapboxgl.Marker; 32 | data: LocationFeature; 33 | }) => void; 34 | children?: React.ReactNode; 35 | } & MarkerOptions; 36 | 37 | export default function Marker({ 38 | children, 39 | latitude, 40 | longitude, 41 | data, 42 | onHover, 43 | onClick, 44 | ...markerOptions 45 | }: Props) { 46 | const { map } = useMap(); 47 | const elementRef = useRef(document.createElement("div")); 48 | const markerRef = useRef(null); 49 | const position = useMemo<[number, number]>( 50 | () => [longitude, latitude], 51 | [longitude, latitude] 52 | ); 53 | 54 | useEffect(() => { 55 | if (!map) return; 56 | 57 | const el = elementRef.current; 58 | const marker = new mapboxgl.Marker({ element: el, ...markerOptions }) 59 | .setLngLat(position) 60 | .addTo(map); 61 | markerRef.current = marker; 62 | 63 | const enter = () => 64 | onHover?.({ 65 | isHovered: true, 66 | position: { longitude: position[0], latitude: position[1] }, 67 | marker, 68 | data, 69 | }); 70 | 71 | const leave = () => 72 | onHover?.({ 73 | isHovered: false, 74 | position: { longitude: position[0], latitude: position[1] }, 75 | marker, 76 | data, 77 | }); 78 | 79 | const click = () => 80 | onClick?.({ 81 | position: { longitude: position[0], latitude: position[1] }, 82 | marker, 83 | data, 84 | }); 85 | 86 | el.addEventListener("mouseenter", enter); 87 | el.addEventListener("mouseleave", leave); 88 | el.addEventListener("click", click); 89 | 90 | return () => { 91 | el.removeEventListener("mouseenter", enter); 92 | el.removeEventListener("mouseleave", leave); 93 | el.removeEventListener("click", click); 94 | marker.remove(); 95 | markerRef.current = null; 96 | }; 97 | }, [map, markerOptions, data, onHover, onClick]); 98 | 99 | useEffect(() => { 100 | if (!markerRef.current) return; 101 | markerRef.current.setLngLat(position); 102 | }, [position]); 103 | 104 | return createPortal(children, elementRef.current); 105 | } 106 | -------------------------------------------------------------------------------- /src/lib/mapbox/api.ts: -------------------------------------------------------------------------------- 1 | import { LocationSuggestion, LocationFeature } from "./utils"; 2 | 3 | const MAPBOX_API_BASE = "https://api.mapbox.com/search/searchbox/v1"; 4 | 5 | function getSessionToken(): string { 6 | if (typeof window !== "undefined") { 7 | const stored = sessionStorage.getItem("mapbox_session_token"); 8 | if (stored) return stored; 9 | 10 | const token = process.env.NEXT_PUBLIC_MAPBOX_SESSION_TOKEN ?? ""; 11 | sessionStorage.setItem("mapbox_session_token", token); 12 | return token; 13 | } 14 | return ""; 15 | } 16 | 17 | export interface SearchOptions { 18 | query: string; 19 | country?: string; 20 | limit?: number; 21 | proximity?: [number, number]; // [longitude, latitude] 22 | signal?: AbortSignal; 23 | } 24 | 25 | export interface SearchResponse { 26 | suggestions: LocationSuggestion[]; 27 | } 28 | 29 | export interface RetrieveResponse { 30 | features: LocationFeature[]; 31 | } 32 | 33 | export async function searchLocations( 34 | options: SearchOptions 35 | ): Promise { 36 | const { query, country = "US", limit = 5, proximity, signal } = options; 37 | 38 | const accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN; 39 | if (!accessToken) { 40 | throw new Error("MAPBOX_TOKEN is not configured"); 41 | } 42 | 43 | const sessionToken = getSessionToken(); 44 | const params = new URLSearchParams({ 45 | q: query, 46 | access_token: accessToken, 47 | session_token: sessionToken, 48 | country, 49 | limit: limit.toString(), 50 | }); 51 | 52 | if (proximity) { 53 | params.append("proximity", `${proximity[0]},${proximity[1]}`); 54 | } 55 | 56 | const url = `${MAPBOX_API_BASE}/suggest?${params.toString()}`; 57 | 58 | try { 59 | const response = await fetch(url, { signal }); 60 | 61 | if (!response.ok) { 62 | throw new Error( 63 | `Mapbox API error: ${response.status} ${response.statusText}` 64 | ); 65 | } 66 | 67 | const data: SearchResponse = await response.json(); 68 | return data.suggestions ?? []; 69 | } catch (error) { 70 | if (error instanceof Error && error.name === "AbortError") { 71 | throw error; 72 | } 73 | if (error instanceof Error) { 74 | throw new Error(`Failed to search locations: ${error.message}`); 75 | } 76 | throw new Error("Failed to search locations: Unknown error"); 77 | } 78 | } 79 | 80 | export async function retrieveLocation( 81 | mapboxId: string, 82 | signal?: AbortSignal 83 | ): Promise { 84 | const accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN; 85 | if (!accessToken) { 86 | throw new Error("MAPBOX_TOKEN is not configured"); 87 | } 88 | 89 | const sessionToken = getSessionToken(); 90 | const url = `${MAPBOX_API_BASE}/retrieve/${mapboxId}?access_token=${accessToken}&session_token=${sessionToken}`; 91 | 92 | try { 93 | const response = await fetch(url, { signal }); 94 | 95 | if (!response.ok) { 96 | throw new Error( 97 | `Mapbox API error: ${response.status} ${response.statusText}` 98 | ); 99 | } 100 | 101 | const data: RetrieveResponse = await response.json(); 102 | return data.features ?? []; 103 | } catch (error) { 104 | if (error instanceof Error && error.name === "AbortError") { 105 | throw error; 106 | } 107 | if (error instanceof Error) { 108 | throw new Error(`Failed to retrieve location: ${error.message}`); 109 | } 110 | throw new Error("Failed to retrieve location: Unknown error"); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/components/location-popup.tsx: -------------------------------------------------------------------------------- 1 | import { LocationFeature, iconMap } from "@/lib/mapbox/utils"; 2 | import { cn } from "@/lib/utils"; 3 | import { LocateIcon, MapPin } from "lucide-react"; 4 | 5 | import Popup from "./map/map-popup"; 6 | import { Badge } from "./ui/badge"; 7 | 8 | type LocationPopupProps = { 9 | location: LocationFeature; 10 | }; 11 | export function LocationPopup({ location }: LocationPopupProps) { 12 | if (!location) return null; 13 | 14 | const { properties, geometry } = location; 15 | 16 | const name = properties?.name || "Unknown Location"; 17 | const address = properties?.full_address || properties?.address || ""; 18 | const categories = properties?.poi_category || []; 19 | const brand = properties?.brand?.[0] || ""; 20 | const status = properties?.operational_status || ""; 21 | const maki = properties?.maki || ""; 22 | 23 | const lat = geometry?.coordinates?.[1] || properties?.coordinates?.latitude; 24 | const lng = geometry?.coordinates?.[0] || properties?.coordinates?.longitude; 25 | 26 | const getIcon = () => { 27 | const allKeys = [maki, ...(categories || [])]; 28 | 29 | for (const key of allKeys) { 30 | const lower = key?.toLowerCase(); 31 | if (iconMap[lower]) return iconMap[lower]; 32 | } 33 | 34 | return ; 35 | }; 36 | 37 | return ( 38 | 46 |
47 |
48 |
49 | {getIcon()} 50 |
51 |
52 |
53 |

{name}

54 | {status && ( 55 | 62 | {status === "active" ? "Open" : status} 63 | 64 | )} 65 |
66 | {brand && brand !== name && ( 67 |

68 | {brand} 69 |

70 | )} 71 | {address && ( 72 |

73 | 74 | {address} 75 |

76 | )} 77 |
78 |
79 | 80 | {categories.length > 0 && ( 81 |
82 | {categories.slice(0, 3).map((category, index) => ( 83 | 88 | {category} 89 | 90 | ))} 91 | {categories.length > 3 && ( 92 | 93 | +{categories.length - 3} more 94 | 95 | )} 96 |
97 | )} 98 | 99 |
100 |
101 | 102 | ID: {properties?.mapbox_id?.substring(0, 8)}... 103 | 104 | 105 | {lat.toFixed(4)}, {lng.toFixed(4)} 106 | 107 |
108 |
109 |
110 |
111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { XIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Dialog({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function DialogTrigger({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return 19 | } 20 | 21 | function DialogPortal({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return 25 | } 26 | 27 | function DialogClose({ 28 | ...props 29 | }: React.ComponentProps) { 30 | return 31 | } 32 | 33 | function DialogOverlay({ 34 | className, 35 | ...props 36 | }: React.ComponentProps) { 37 | return ( 38 | 46 | ) 47 | } 48 | 49 | function DialogContent({ 50 | className, 51 | children, 52 | ...props 53 | }: React.ComponentProps) { 54 | return ( 55 | 56 | 57 | 65 | {children} 66 | 67 | 68 | Close 69 | 70 | 71 | 72 | ) 73 | } 74 | 75 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { 76 | return ( 77 |
82 | ) 83 | } 84 | 85 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { 86 | return ( 87 |
95 | ) 96 | } 97 | 98 | function DialogTitle({ 99 | className, 100 | ...props 101 | }: React.ComponentProps) { 102 | return ( 103 | 108 | ) 109 | } 110 | 111 | function DialogDescription({ 112 | className, 113 | ...props 114 | }: React.ComponentProps) { 115 | return ( 116 | 121 | ) 122 | } 123 | 124 | export { 125 | Dialog, 126 | DialogClose, 127 | DialogContent, 128 | DialogDescription, 129 | DialogFooter, 130 | DialogHeader, 131 | DialogOverlay, 132 | DialogPortal, 133 | DialogTitle, 134 | DialogTrigger, 135 | } 136 | -------------------------------------------------------------------------------- /src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Command as CommandPrimitive } from "cmdk"; 5 | import { SearchIcon } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | import { 9 | Dialog, 10 | DialogContent, 11 | DialogDescription, 12 | DialogHeader, 13 | DialogTitle, 14 | } from "@/components/ui/dialog"; 15 | 16 | function Command({ 17 | className, 18 | ...props 19 | }: React.ComponentProps) { 20 | return ( 21 | 29 | ); 30 | } 31 | 32 | function CommandDialog({ 33 | title = "Command Palette", 34 | description = "Search for a command to run...", 35 | children, 36 | ...props 37 | }: React.ComponentProps & { 38 | title?: string; 39 | description?: string; 40 | }) { 41 | return ( 42 | 43 | 44 | {title} 45 | {description} 46 | 47 | 48 | 49 | {children} 50 | 51 | 52 | 53 | ); 54 | } 55 | 56 | function CommandInput({ 57 | className, 58 | ...props 59 | }: React.ComponentProps) { 60 | return ( 61 |
65 | 66 | 74 |
75 | ); 76 | } 77 | 78 | function CommandList({ 79 | className, 80 | ...props 81 | }: React.ComponentProps) { 82 | return ( 83 | 91 | ); 92 | } 93 | 94 | function CommandEmpty({ 95 | ...props 96 | }: React.ComponentProps) { 97 | return ( 98 | 103 | ); 104 | } 105 | 106 | function CommandGroup({ 107 | className, 108 | ...props 109 | }: React.ComponentProps) { 110 | return ( 111 | 119 | ); 120 | } 121 | 122 | function CommandSeparator({ 123 | className, 124 | ...props 125 | }: React.ComponentProps) { 126 | return ( 127 | 132 | ); 133 | } 134 | 135 | function CommandItem({ 136 | className, 137 | ...props 138 | }: React.ComponentProps) { 139 | return ( 140 | 148 | ); 149 | } 150 | 151 | function CommandShortcut({ 152 | className, 153 | ...props 154 | }: React.ComponentProps<"span">) { 155 | return ( 156 | 164 | ); 165 | } 166 | 167 | export { 168 | Command, 169 | CommandDialog, 170 | CommandInput, 171 | CommandList, 172 | CommandEmpty, 173 | CommandGroup, 174 | CommandItem, 175 | CommandShortcut, 176 | CommandSeparator, 177 | }; 178 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | @theme inline { 7 | --color-background: var(--background); 8 | --color-foreground: var(--foreground); 9 | --font-sans: var(--font-geist-sans); 10 | --font-mono: var(--font-geist-mono); 11 | --color-sidebar-ring: var(--sidebar-ring); 12 | --color-sidebar-border: var(--sidebar-border); 13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 14 | --color-sidebar-accent: var(--sidebar-accent); 15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 16 | --color-sidebar-primary: var(--sidebar-primary); 17 | --color-sidebar-foreground: var(--sidebar-foreground); 18 | --color-sidebar: var(--sidebar); 19 | --color-chart-5: var(--chart-5); 20 | --color-chart-4: var(--chart-4); 21 | --color-chart-3: var(--chart-3); 22 | --color-chart-2: var(--chart-2); 23 | --color-chart-1: var(--chart-1); 24 | --color-ring: var(--ring); 25 | --color-input: var(--input); 26 | --color-border: var(--border); 27 | --color-destructive: var(--destructive); 28 | --color-accent-foreground: var(--accent-foreground); 29 | --color-accent: var(--accent); 30 | --color-muted-foreground: var(--muted-foreground); 31 | --color-muted: var(--muted); 32 | --color-secondary-foreground: var(--secondary-foreground); 33 | --color-secondary: var(--secondary); 34 | --color-primary-foreground: var(--primary-foreground); 35 | --color-primary: var(--primary); 36 | --color-popover-foreground: var(--popover-foreground); 37 | --color-popover: var(--popover); 38 | --color-card-foreground: var(--card-foreground); 39 | --color-card: var(--card); 40 | --radius-sm: calc(var(--radius) - 4px); 41 | --radius-md: calc(var(--radius) - 2px); 42 | --radius-lg: var(--radius); 43 | --radius-xl: calc(var(--radius) + 4px); 44 | } 45 | 46 | :root { 47 | --radius: 0.625rem; 48 | --background: oklch(1 0 0); 49 | --foreground: oklch(0.145 0 0); 50 | --card: oklch(1 0 0); 51 | --card-foreground: oklch(0.145 0 0); 52 | --popover: oklch(1 0 0); 53 | --popover-foreground: oklch(0.145 0 0); 54 | --primary: oklch(0.205 0 0); 55 | --primary-foreground: oklch(0.985 0 0); 56 | --secondary: oklch(0.97 0 0); 57 | --secondary-foreground: oklch(0.205 0 0); 58 | --muted: oklch(0.97 0 0); 59 | --muted-foreground: oklch(0.556 0 0); 60 | --accent: oklch(0.97 0 0); 61 | --accent-foreground: oklch(0.205 0 0); 62 | --destructive: oklch(0.577 0.245 27.325); 63 | --border: oklch(0.922 0 0); 64 | --input: oklch(0.922 0 0); 65 | --ring: oklch(0.708 0 0); 66 | --chart-1: oklch(0.646 0.222 41.116); 67 | --chart-2: oklch(0.6 0.118 184.704); 68 | --chart-3: oklch(0.398 0.07 227.392); 69 | --chart-4: oklch(0.828 0.189 84.429); 70 | --chart-5: oklch(0.769 0.188 70.08); 71 | --sidebar: oklch(0.985 0 0); 72 | --sidebar-foreground: oklch(0.145 0 0); 73 | --sidebar-primary: oklch(0.205 0 0); 74 | --sidebar-primary-foreground: oklch(0.985 0 0); 75 | --sidebar-accent: oklch(0.97 0 0); 76 | --sidebar-accent-foreground: oklch(0.205 0 0); 77 | --sidebar-border: oklch(0.922 0 0); 78 | --sidebar-ring: oklch(0.708 0 0); 79 | } 80 | 81 | .dark { 82 | --background: oklch(0.145 0 0); 83 | --foreground: oklch(0.985 0 0); 84 | --card: oklch(0.205 0 0); 85 | --card-foreground: oklch(0.985 0 0); 86 | --popover: oklch(0.205 0 0); 87 | --popover-foreground: oklch(0.985 0 0); 88 | --primary: oklch(0.922 0 0); 89 | --primary-foreground: oklch(0.205 0 0); 90 | --secondary: oklch(0.269 0 0); 91 | --secondary-foreground: oklch(0.985 0 0); 92 | --muted: oklch(0.269 0 0); 93 | --muted-foreground: oklch(0.708 0 0); 94 | --accent: oklch(0.269 0 0); 95 | --accent-foreground: oklch(0.985 0 0); 96 | --destructive: oklch(0.704 0.191 22.216); 97 | --border: oklch(1 0 0 / 10%); 98 | --input: oklch(1 0 0 / 15%); 99 | --ring: oklch(0.556 0 0); 100 | --chart-1: oklch(0.488 0.243 264.376); 101 | --chart-2: oklch(0.696 0.17 162.48); 102 | --chart-3: oklch(0.769 0.188 70.08); 103 | --chart-4: oklch(0.627 0.265 303.9); 104 | --chart-5: oklch(0.645 0.246 16.439); 105 | --sidebar: oklch(0.205 0 0); 106 | --sidebar-foreground: oklch(0.985 0 0); 107 | --sidebar-primary: oklch(0.488 0.243 264.376); 108 | --sidebar-primary-foreground: oklch(0.985 0 0); 109 | --sidebar-accent: oklch(0.269 0 0); 110 | --sidebar-accent-foreground: oklch(0.985 0 0); 111 | --sidebar-border: oklch(1 0 0 / 10%); 112 | --sidebar-ring: oklch(0.556 0 0); 113 | } 114 | 115 | /* Custom Mapbox Popup Styling */ 116 | .mapboxgl-custom-popup .mapboxgl-popup-content { 117 | @apply bg-card text-card-foreground p-5 rounded-lg; 118 | } 119 | 120 | .mapboxgl-custom-popup .mapboxgl-popup-close-button { 121 | font-size: 22px; 122 | padding: 0 6px; 123 | right: 0; 124 | top: 0; 125 | } 126 | 127 | .mapboxgl-custom-popup .mapboxgl-popup-close-button:hover { 128 | background-color: transparent; 129 | } 130 | 131 | .mapboxgl-popup-anchor-top .mapboxgl-popup-tip { 132 | border-bottom-color: var(--card); 133 | border-top-color: transparent; 134 | border-left-color: transparent; 135 | border-right-color: transparent; 136 | } 137 | 138 | .mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip { 139 | border-top-color: var(--card); 140 | border-bottom-color: transparent; 141 | border-left-color: transparent; 142 | border-right-color: transparent; 143 | } 144 | 145 | .mapboxgl-popup-anchor-left .mapboxgl-popup-tip { 146 | border-right-color: var(--card); 147 | border-top-color: transparent; 148 | border-bottom-color: transparent; 149 | border-left-color: transparent; 150 | } 151 | 152 | .mapboxgl-popup-anchor-right .mapboxgl-popup-tip { 153 | border-left-color: var(--card); 154 | border-top-color: transparent; 155 | border-bottom-color: transparent; 156 | border-right-color: transparent; 157 | } 158 | 159 | 160 | .dark .mapboxgl-popup-anchor-top .mapboxgl-popup-tip { 161 | border-bottom-color: var(--card); 162 | } 163 | .dark .mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip { 164 | border-top-color: var(--card); 165 | } 166 | .dark .mapboxgl-popup-anchor-left .mapboxgl-popup-tip { 167 | border-right-color: var(--card); 168 | } 169 | .dark .mapboxgl-popup-anchor-right .mapboxgl-popup-tip { 170 | border-left-color: var(--card); 171 | } 172 | 173 | 174 | @layer base { 175 | * { 176 | @apply border-border outline-ring/50; 177 | button, [role="button"] { 178 | cursor: pointer; 179 | } 180 | } 181 | body { 182 | @apply bg-background text-foreground; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/components/map/map-search.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect, useRef, useCallback } from "react"; 4 | import { Loader, MapPin, X } from "lucide-react"; 5 | 6 | import { 7 | Command, 8 | CommandInput, 9 | CommandList, 10 | CommandEmpty, 11 | CommandGroup, 12 | CommandItem, 13 | } from "@/components/ui/command"; 14 | import { useDebounce } from "@/hooks/useDebounce"; 15 | import { useMap } from "@/context/map-context"; 16 | import { cn } from "@/lib/utils"; 17 | import { 18 | iconMap, 19 | LocationFeature, 20 | LocationSuggestion, 21 | } from "@/lib/mapbox/utils"; 22 | import { searchLocations, retrieveLocation } from "@/lib/mapbox/api"; 23 | import { MAP_CONSTANTS } from "@/lib/mapbox/constants"; 24 | import { LocationMarker } from "../location-marker"; 25 | import { LocationPopup } from "../location-popup"; 26 | 27 | export default function MapSearch() { 28 | const { map } = useMap(); 29 | const [query, setQuery] = useState(""); 30 | const [displayValue, setDisplayValue] = useState(""); 31 | const [results, setResults] = useState([]); 32 | const [isSearching, setIsSearching] = useState(false); 33 | const [isOpen, setIsOpen] = useState(false); 34 | const [selectedLocation, setSelectedLocation] = 35 | useState(null); 36 | const [selectedLocations, setSelectedLocations] = useState( 37 | [] 38 | ); 39 | const [error, setError] = useState(null); 40 | 41 | const abortControllerRef = useRef(null); 42 | const debouncedQuery = useDebounce(query, MAP_CONSTANTS.SEARCH.DEBOUNCE_MS); 43 | 44 | // Search for locations 45 | useEffect(() => { 46 | // Cancel previous request if it exists 47 | if (abortControllerRef.current) { 48 | abortControllerRef.current.abort(); 49 | } 50 | 51 | if (!debouncedQuery.trim()) { 52 | setResults([]); 53 | setIsOpen(false); 54 | setError(null); 55 | return; 56 | } 57 | 58 | const performSearch = async () => { 59 | setIsSearching(true); 60 | setIsOpen(true); 61 | setError(null); 62 | 63 | // Create new abort controller for this request 64 | const abortController = new AbortController(); 65 | abortControllerRef.current = abortController; 66 | 67 | try { 68 | const suggestions = await searchLocations({ 69 | query: debouncedQuery, 70 | country: MAP_CONSTANTS.SEARCH.DEFAULT_COUNTRY, 71 | limit: MAP_CONSTANTS.SEARCH.DEFAULT_LIMIT, 72 | proximity: MAP_CONSTANTS.SEARCH.DEFAULT_PROXIMITY, 73 | signal: abortController.signal, 74 | }); 75 | 76 | if (abortController.signal.aborted) return; 77 | 78 | setResults(suggestions); 79 | } catch (err) { 80 | if (err instanceof Error && err.name === "AbortError") { 81 | return; 82 | } 83 | 84 | console.error("Search error:", err); 85 | setError( 86 | err instanceof Error ? err.message : "Failed to search locations" 87 | ); 88 | setResults([]); 89 | } finally { 90 | if (!abortController.signal.aborted) { 91 | setIsSearching(false); 92 | } 93 | } 94 | }; 95 | 96 | performSearch(); 97 | 98 | // Cleanup: abort request on unmount or query change 99 | return () => { 100 | if (abortControllerRef.current) { 101 | abortControllerRef.current.abort(); 102 | } 103 | }; 104 | }, [debouncedQuery]); 105 | 106 | // Handle input change 107 | const handleInputChange = useCallback((value: string) => { 108 | setQuery(value); 109 | setDisplayValue(value); 110 | }, []); 111 | 112 | // Handle location selection 113 | const handleSelect = useCallback( 114 | async (suggestion: LocationSuggestion) => { 115 | if (!map) return; 116 | 117 | setIsSearching(true); 118 | setError(null); 119 | 120 | try { 121 | const features = await retrieveLocation(suggestion.mapbox_id); 122 | 123 | if (features.length === 0) { 124 | setError("No location details found"); 125 | return; 126 | } 127 | 128 | const [feature] = features; 129 | const coordinates = feature.geometry.coordinates; 130 | 131 | map.flyTo({ 132 | center: coordinates, 133 | zoom: MAP_CONSTANTS.FLY_TO.ZOOM, 134 | speed: MAP_CONSTANTS.FLY_TO.SPEED, 135 | duration: MAP_CONSTANTS.FLY_TO.DURATION, 136 | essential: true, 137 | }); 138 | 139 | setDisplayValue(suggestion.name); 140 | setSelectedLocations(features); 141 | setResults([]); 142 | setIsOpen(false); 143 | } catch (err) { 144 | console.error("Retrieve error:", err); 145 | setError( 146 | err instanceof Error ? err.message : "Failed to retrieve location" 147 | ); 148 | } finally { 149 | setIsSearching(false); 150 | } 151 | }, 152 | [map] 153 | ); 154 | 155 | // Clear search 156 | const clearSearch = useCallback(() => { 157 | // Abort any pending requests 158 | if (abortControllerRef.current) { 159 | abortControllerRef.current.abort(); 160 | } 161 | 162 | setQuery(""); 163 | setDisplayValue(""); 164 | setResults([]); 165 | setIsOpen(false); 166 | setError(null); 167 | setSelectedLocation(null); 168 | setSelectedLocations([]); 169 | }, []); 170 | 171 | const hasResults = results.length > 0; 172 | const showEmptyState = 173 | isOpen && !isSearching && query.trim() && !hasResults && !error; 174 | 175 | return ( 176 | <> 177 |
178 | 179 |
185 | 191 | {displayValue && !isSearching && ( 192 | 197 | )} 198 | {isSearching && ( 199 | 203 | )} 204 |
205 | 206 | {isOpen && ( 207 | 208 | {error ? ( 209 | 210 |
211 |

212 | Error occurred 213 |

214 |

{error}

215 |
216 |
217 | ) : showEmptyState ? ( 218 | 219 |
220 |

No locations found

221 |

222 | Try a different search term 223 |

224 |
225 |
226 | ) : hasResults ? ( 227 | 228 | {results.map((location) => ( 229 | handleSelect(location)} 232 | value={`${location.name} ${location.place_formatted} ${location.mapbox_id}`} 233 | className="flex items-center py-3 px-2 cursor-pointer hover:bg-accent rounded-md" 234 | > 235 |
236 |
237 | {location.maki && iconMap[location.maki] ? ( 238 | iconMap[location.maki] 239 | ) : ( 240 | 241 | )} 242 |
243 |
244 | 245 | {location.name} 246 | 247 | 248 | {location.place_formatted} 249 | 250 |
251 |
252 |
253 | ))} 254 |
255 | ) : null} 256 |
257 | )} 258 |
259 |
260 | 261 | {selectedLocations.map((location) => ( 262 | setSelectedLocation(data)} 266 | /> 267 | ))} 268 | 269 | {selectedLocation && } 270 | 271 | ); 272 | } 273 | --------------------------------------------------------------------------------