Build a Workshop Finder with React

Build a location-aware map app with React, Leaflet, and the Geolocation API.

Introduction

30 Second Summary

Imagine your car breaks down on an unfamiliar road. The first thing you do is pull out your phone and search for the nearest mechanic.

In this project, you will build a "Workshop Finder" web app that detects your GPS location, displays nearby vehicle repair workshops on an interactive map, and lets you report issues and leave feedback.

What You'll Build

A React application powered by Leaflet maps that reads your browser's live GPS coordinates, plots workshop markers on an OpenStreetMap-powered map, and lets users report vehicle issues through a form.

By the end of this project, you'll have:

  • A React project scaffolded with Vite, running a local development server.
  • A geolocation hook that reads your browser's GPS and centers the map on your real location.
  • Interactive workshop markers with popup details, an issue reporting form, and a feedback textarea.
  • Secret Mission: Add star ratings and a filtered workshop list panel.

Are there any prerequisites?

You should have a basic understanding of HTML, CSS, and JavaScript syntax. You will also need Node.js installed on your machine. No prior React experience is required.

Scaffold the React Project

Before you can build the Workshop Finder, you need a working React project with the right tools installed.

In this step, you will scaffold a new React project using Vite, a fast build tool that gives you instant feedback as you code. You will also install the map libraries that power the Workshop Finder's interactive map.

In this step, get ready to:
  • Scaffold a Vite + React project and confirm the dev server runs.
  • Install the React Leaflet and Leaflet map libraries.
  • Clean up starter files and configure global styles.
Create a new Vite + React project

Vite is a modern build tool that starts a dev server in milliseconds and refreshes the browser every time you save a file. You will use it to scaffold a ready-to-go React project with one command.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Workshop Finder project by running this command:
npm create vite@latest workshop-finder -- --template react

What does this command do?

  • npm create vite@latest downloads and runs the Vite project scaffolder.
  • workshop-finder is the folder name for your new project.
  • -- --template react tells Vite to use the React starter template, skipping the interactive menu.

"Ok to proceed?" prompt

The first time you run this, npm may ask "Need to install the following packages: create-vite@latest. Ok to proceed? (y)". Press Enter or type y to continue.

  • Navigate into the new project folder and install its base dependencies by running these commands:
cd workshop-finder
npm install

What does npm install do?

npm install reads the package.json file that Vite generated and downloads all the libraries your project needs (React, ReactDOM, etc.) into a node_modules folder.

  • Start the development server by running:
npm run dev
  • Open your browser and go to http://localhost:5173.
  • You should see the Vite + React starter page with a spinning logo and a counter button.

Don't see the starter page?

  • Make sure you ran npm install before npm run dev. Without it, the dependencies are missing.
  • Check that the terminal shows a URL like http://localhost:5173. If you see a different port number, use that URL instead.
  • help me debug my Vite dev server
Install map and styling dependencies

Your Workshop Finder needs two libraries to render interactive maps. Leaflet is the map engine, and React Leaflet wraps it in React components you can drop into your app like any other component.

  • Stop the dev server by pressing Ctrl+C in your terminal.
  • Install the map libraries by running:
npm install react-leaflet leaflet
  • You should see a success message with no errors once the install finishes.

Why Leaflet instead of Google Maps?

Google Maps requires a paid API key, even on the free tier. Leaflet uses OpenStreetMap tiles, which are completely free with no API key, no account, and no usage limits.

Seeing install errors?

  • Make sure you are inside the workshop-finder folder. Run pwd (macOS/Linux) or cd (Windows) to check your current location.
  • help me fix npm install errors for react-leaflet
Clean up the starter files and add Leaflet CSS

The Vite scaffold comes with demo code you do not need. You will strip it out and set up the global styles that the map needs to fill the screen.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the workshop-finder folder on your Desktop, then click Open.
  • In the Explorer sidebar on the left, expand the src folder.
  • Click on App.jsx to open it.
  • Select all the existing code (Cmd+A on macOS, Ctrl+A on Windows) and replace it with:
function App() {
  return <h1>Workshop Finder</h1>
}

export default App

What does this code do?

This is the simplest possible React component. The App function returns a heading element that React renders to the page. You are starting from a blank slate so every line of code you add from here is intentional.

  • Save App.jsx (Cmd+S on macOS, Ctrl+S on Windows).
  • Click on App.css in the Explorer sidebar.
  • Select all and delete everything in this file so it is completely empty.
  • Save App.css.
  • Click on index.css in the Explorer sidebar.
  • Select all and replace the contents with the following code:
@import "leaflet/dist/leaflet.css";

body, #root {
  margin: 0;
  padding: 0;
  height: 100vh;
  font-family: system-ui, sans-serif;
}

What does this code do?

  • @import "leaflet/dist/leaflet.css" loads the stylesheet that Leaflet needs to render map tiles and controls correctly.
  • The body, #root rule removes default browser margins, sets the page height to the full viewport, and applies a clean system font.
  • Without height: 100vh, the map container would collapse to zero height and be invisible.
  • Save index.css.
  • Return to your terminal and start the dev server again by running:
npm run dev
  • Open http://localhost:5173 in your browser.
  • You should see a blank page with just the text "Workshop Finder" in large bold letters.

Still seeing the old Vite demo page?

  • Make sure you saved all three files (App.jsx, App.css, index.css). Look for a white dot on the file tab in VS Code, which means unsaved changes.
  • Try a hard refresh in your browser: Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).
  • help me debug why my App.jsx changes aren't showing

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is what each file should look like at the end of this step.

function App() {
  return <h1>Workshop Finder</h1>
}

export default App

Your src/App.css file should be completely empty with no content at all.

@import "leaflet/dist/leaflet.css";

body, #root {
  margin: 0;
  padding: 0;
  height: 100vh;
  font-family: system-ui, sans-serif;
}
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

Your React project is scaffolded and the map libraries are ready to go. Next up, you will build the MapView component and see a real interactive map fill your screen.

Build the Map Component

Your project is scaffolded and the dev server works. Now you need the core visual element of the Workshop Finder: an interactive map.

React Leaflet provides React components that wrap the Leaflet JavaScript mapping library. Instead of writing imperative map code, you declare what you want (a map container, a tile layer, markers) and React handles the rest.

In this step, get ready to:
  • Create a MapView component that renders a full-screen interactive map.
  • Render the map in your app and verify it loads.
  • Fix a known Leaflet icon issue that affects bundled apps.
Create the MapView component

React Leaflet's MapContainer creates the map instance and provides it to child components via React context. TileLayer loads the actual map images (tiles) from OpenStreetMap's free servers.

  • Create a new folder called components inside your src folder.
  • Create a new file called MapView.jsx inside src/components/.
  • Paste the following code into MapView.jsx:
import { MapContainer, TileLayer, Circle, useMap } from 'react-leaflet'
import { useEffect } from 'react'

// Helper component that flies the map to the user's position
function FlyToUser({ position }) {
  const map = useMap()
  useEffect(() => {
    if (position) {
      map.flyTo([position.lat, position.lng], 14)
    }
  }, [position, map])
  return null
}

// Main map component
function MapView({ userPosition, onReportIssue }) {
  return (
    <MapContainer
      center={[userPosition.lat, userPosition.lng]}
      zoom={14}
      style={{ height: '100%', width: '100%' }}
    >
      <TileLayer
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
      />
      <FlyToUser position={userPosition} />
      <Circle
        center={[userPosition.lat, userPosition.lng]}
        radius={50}
        pathOptions={{ color: '#1976d2', fillColor: '#1976d2', fillOpacity: 0.3 }}
      />
    </MapContainer>
  )
}

export default MapView

What does this code do?

MapContainer initialises the Leaflet map and passes it down to all child components via React context. The center and zoom props set the initial view, but they are immutable after the first render. Changing them later does nothing.

TileLayer fetches the visible map images from OpenStreetMap's free tile server. No API key needed.

FlyToUser is a separate component because the useMap hook only works inside a descendant of MapContainer. It smoothly animates the map to the user's coordinates.

Circle draws a blue dot at the user's location to show "you are here."

  • Save MapView.jsx.

You won't see a change in the browser yet. The next chunk connects MapView to your app.

Add MapView to App.jsx

Your MapView component exists but nothing renders it yet. You need to import it into App.jsx and pass it a position to center on.

  • Open src/App.jsx and replace the entire contents with this code:
import MapView from './components/MapView'

// Hardcoded position for now (Hyderabad, India)
function App() {
  return (
    <div style={{ height: '100vh' }}>
      <MapView userPosition={{ lat: 17.385, lng: 78.4867 }} onReportIssue={() => {}} />
    </div>
  )
}

export default App

What does this code do?

The wrapping div with height: '100vh' ensures the map fills the entire browser window. Without an explicit height, Leaflet maps collapse to zero pixels.

userPosition passes a hardcoded latitude and longitude (Hyderabad, India) so the map has somewhere to center. onReportIssue is an empty function placeholder you will connect later.

  • Save App.jsx.
  • Switch to your browser at http://localhost:5173. You should see a full-screen interactive map centered on Hyderabad with a small blue circle.

Don't see the map?

  • If the page is blank, check that src/index.css still has @import "leaflet/dist/leaflet.css"; at the top. Without Leaflet's stylesheet the tiles load but the map has no visible size.
  • If you see a grey box with no tiles, confirm the url in your TileLayer exactly matches https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png.
  • Make sure your dev server is still running. If you stopped it, run npm run dev again in your terminal.

Still stuck? Help me debug why my React Leaflet map is not rendering.

Fix the Leaflet marker icon issue

Leaflet's default marker icons break in bundled apps like Vite projects because the image paths get lost during the build process. You will add markers in a later step, so let's fix this now to avoid confusing errors later.

  • In src/components/MapView.jsx, find this line at the top of the file:
import { useEffect } from 'react'
  • Add the following import directly below it:
import L from 'leaflet'
  • Now find the FlyToUser function. Add the following block of code above it (between the imports and the FlyToUser function):
// Fix Leaflet's broken default marker icon paths in bundled apps
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
  iconRetinaUrl: new URL('leaflet/dist/images/marker-icon-2x.png', import.meta.url).href,
  iconUrl: new URL('leaflet/dist/images/marker-icon.png', import.meta.url).href,
  shadowUrl: new URL('leaflet/dist/images/marker-shadow.png', import.meta.url).href,
})

Why is this fix needed?

Leaflet tries to load marker images using CSS url() paths, but Vite rewrites those paths during bundling. The fix manually tells Leaflet where to find the images using import.meta.url, which Vite resolves correctly.

You won't see any visual change right now because you have no markers yet. This prevents broken icon errors when you add workshop markers in the next step.

  • Save MapView.jsx.
  • Confirm the map still loads normally in your browser. The blue circle should still appear with no console errors.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Here is the complete src/components/MapView.jsx:

import { MapContainer, TileLayer, Circle, useMap } from 'react-leaflet'
import { useEffect } from 'react'
import L from 'leaflet'

delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
  iconRetinaUrl: new URL('leaflet/dist/images/marker-icon-2x.png', import.meta.url).href,
  iconUrl: new URL('leaflet/dist/images/marker-icon.png', import.meta.url).href,
  shadowUrl: new URL('leaflet/dist/images/marker-shadow.png', import.meta.url).href,
})

function FlyToUser({ position }) {
  const map = useMap()
  useEffect(() => {
    if (position) {
      map.flyTo([position.lat, position.lng], 14)
    }
  }, [position, map])
  return null
}

function MapView({ userPosition, onReportIssue }) {
  return (
    <MapContainer
      center={[userPosition.lat, userPosition.lng]}
      zoom={14}
      style={{ height: '100%', width: '100%' }}
    >
      <TileLayer
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
      />
      <FlyToUser position={userPosition} />
      <Circle
        center={[userPosition.lat, userPosition.lng]}
        radius={50}
        pathOptions={{ color: '#1976d2', fillColor: '#1976d2', fillOpacity: 0.3 }}
      />
    </MapContainer>
  )
}

export default MapView

And here is the complete src/App.jsx:

import MapView from './components/MapView'

function App() {
  return (
    <div style={{ height: '100vh' }}>
      <MapView userPosition={{ lat: 17.385, lng: 78.4867 }} onReportIssue={() => {}} />
    </div>
  )
}

export default App

Your map is alive. You can pan, zoom, and see the blue "you are here" circle. Next up, you will connect real GPS coordinates so the map centers on your actual location.

Add Live Geolocation

Your map is rendering beautifully, but it's stuck on hardcoded coordinates in Hyderabad.

In this step, you'll tap into the browser's Geolocation API to read the user's real GPS position and fly the map there automatically.

In this step, get ready to:
  • Create a custom React hook that reads the browser's GPS.
  • Wire the hook into your App so the map uses your real location.
  • Verify the map flies to your real-world coordinates on page load.
Create a useGeolocation custom hook

A custom hook is a JavaScript function whose name starts with use. It lets you package up logic (like calling the browser's GPS) and reuse it in any component.

  • Create a new folder inside src called hooks. In VS Code, right-click the src folder in the Explorer sidebar and select New Folder.
  • Inside the new hooks folder, create a file called useGeolocation.js. Right-click hooks and select New File.
  • Add the following code to src/hooks/useGeolocation.js:
import { useState, useEffect } from 'react'

function useGeolocation() {
  const [position, setPosition] = useState(null)
  const [error, setError] = useState(null)

  useEffect(() => {
    if (!navigator.geolocation) {
      setError('Geolocation is not supported by your browser')
      return
    }

    navigator.geolocation.getCurrentPosition(
      (pos) => {
        setPosition({
          lat: pos.coords.latitude,
          lng: pos.coords.longitude,
        })
      },
      (err) => {
        setError(err.message)
      },
      {
        enableHighAccuracy: true,
        timeout: 10000,
        maximumAge: 0,
      }
    )
  }, [])

  return { position, error }
}

export default useGeolocation

What does this code do?

  • useState creates two pieces of state: position (starts as null until GPS responds) and error (stays null unless something goes wrong).
  • useEffect runs code after the component renders. The empty [] at the end means it runs only once (on mount), not every re-render.
  • navigator.geolocation.getCurrentPosition() asks the browser for the device's GPS coordinates. It takes a success callback, an error callback, and an options object.
  • enableHighAccuracy: true requests the best possible position. timeout: 10000 gives up after 10 seconds. maximumAge: 0 forces a fresh reading instead of a cached one.

What is useState?

useState lets a component "remember" a value between renders. Every time you call the setter (like setPosition), React re-renders the component with the new value.

  • Save useGeolocation.js.

Seeing a red underline in VS Code?

If VS Code shows a squiggly line under the imports, make sure the file is saved inside src/hooks/ (not directly in src/). The folder structure matters for the import path you'll use next.

Help me debug my useGeolocation hook file

Connect geolocation to the MapView

Your hook is ready, but nothing uses it yet. You need to call it from App.jsx so the app can show a loading state while waiting for GPS, handle errors, and pass the real coordinates down to MapView.

First, you'll add some CSS classes that the updated App will use for the loading and error screens.

  • Open src/index.css and add the following styles below the existing body, #root rule:
.app-container {
  display: flex;
  height: 100vh;
  position: relative;
}

.map-wrapper {
  flex: 1;
  height: 100%;
}

.loading-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  font-size: 1.2rem;
  color: #555;
}

.error-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  font-size: 1.2rem;
  color: #d32f2f;
}

What do these classes do?

  • .app-container uses flexbox to lay out the map (and later, a sidebar).
  • .map-wrapper takes up all remaining horizontal space with flex: 1.
  • .loading-screen and .error-screen center text in the viewport so the user sees clear feedback while the GPS loads.
  • Save index.css.
  • Now open src/App.jsx and replace its entire contents with the following:
import { useState } from 'react'
import MapView from './components/MapView'
import useGeolocation from './hooks/useGeolocation'

function App() {
  // Call the custom hook to get GPS position
  const { position, error } = useGeolocation()
  // Track which workshop the user selected (used later)
  const [selectedWorkshop, setSelectedWorkshop] = useState(null)

  // Show error message if location access fails
  if (error) {
    return <div className="error-screen">Location error: {error}</div>
  }

  // Show loading screen while waiting for GPS
  if (!position) {
    return <div className="loading-screen">Detecting your location...</div>
  }

  return (
    <div className="app-container">
      <div className="map-wrapper">
        <MapView
          userPosition={position}
          onReportIssue={setSelectedWorkshop}
        />
      </div>
    </div>
  )
}

export default App

What's happening here?

  • useGeolocation() returns position and error. React re-renders App every time either value changes.
  • The two early if blocks are conditional rendering. If there's an error, the component returns only the error screen. If position is still null, it returns the loading screen.
  • Once position arrives, the full map renders with your real GPS coordinates passed through userPosition={position}.
  • selectedWorkshop is state you'll use in the next step to track which workshop the user clicked.

Why is state defined in App instead of MapView?

This pattern is called "lifting state up". When multiple child components need to read or write the same data, you put the state in their closest shared parent. App is the parent of both MapView and the issue form you'll build later, so it's the right home for selectedWorkshop.

  • Save App.jsx.

Seeing a blank screen or import error?

Check that the import path is exactly ./hooks/useGeolocation (no file extension, starting with ./). Also confirm your file is named useGeolocation.js inside the src/hooks/ folder.

Help me fix my App.jsx import error

Verify the map flies to your real location

The FlyToUser component you built inside MapView in the previous step already handles recentering. Now that App passes real GPS coordinates instead of hardcoded ones, the map will automatically fly to your actual location.

  • Switch to your browser and check the page at http://localhost:5173.
  • Your browser displays a permission prompt asking to share your location. Click Allow.

What should I see?

First you'll see the text "Detecting your location..." centered on the page. After a moment, the map appears and flies to your real-world position with a blue circle marker on top.

Why does the browser ask for permission?

Location is sensitive data. Browsers require explicit user consent before sharing GPS coordinates with any website. During development on localhost, geolocation works without HTTPS. In production, HTTPS is required.

Seeing the error screen instead of the map?

If you clicked Block on the permission prompt, the app shows "Location error" because the browser denied access. To fix this, click the lock icon (or site settings icon) in your browser's address bar, change Location to Allow, and refresh the page.

If the loading screen stays forever, your device may not have GPS or your browser's location service may be disabled in system settings.

Help me get geolocation working

✔️ Awesome, I've got everything!

Great. Double check you've saved both files.

ⓧ I'd like to double check the full code

Here is the full code for each file at this point.

import { useState, useEffect } from 'react'

function useGeolocation() {
  const [position, setPosition] = useState(null)
  const [error, setError] = useState(null)

  useEffect(() => {
    if (!navigator.geolocation) {
      setError('Geolocation is not supported by your browser')
      return
    }

    navigator.geolocation.getCurrentPosition(
      (pos) => {
        setPosition({
          lat: pos.coords.latitude,
          lng: pos.coords.longitude,
        })
      },
      (err) => {
        setError(err.message)
      },
      {
        enableHighAccuracy: true,
        timeout: 10000,
        maximumAge: 0,
      }
    )
  }, [])

  return { position, error }
}

export default useGeolocation
import { useState } from 'react'
import MapView from './components/MapView'
import useGeolocation from './hooks/useGeolocation'

function App() {
  const { position, error } = useGeolocation()
  const [selectedWorkshop, setSelectedWorkshop] = useState(null)

  if (error) {
    return <div className="error-screen">Location error: {error}</div>
  }

  if (!position) {
    return <div className="loading-screen">Detecting your location...</div>
  }

  return (
    <div className="app-container">
      <div className="map-wrapper">
        <MapView
          userPosition={position}
          onReportIssue={setSelectedWorkshop}
        />
      </div>
    </div>
  )
}

export default App
@import "leaflet/dist/leaflet.css";

body, #root {
  margin: 0;
  padding: 0;
  height: 100vh;
  font-family: system-ui, sans-serif;
}

.app-container {
  display: flex;
  height: 100vh;
  position: relative;
}

.map-wrapper {
  flex: 1;
  height: 100%;
}

.loading-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  font-size: 1.2rem;
  color: #555;
}

.error-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  font-size: 1.2rem;
  color: #d32f2f;
}

Your app now detects real GPS coordinates and shows them on the map. Next up, you'll add workshop markers so the traveler can see nearby repair shops.

Display Workshop Markers

Your map now knows exactly where the user is standing. The next challenge is showing them what's nearby.

In this step, you'll populate the map with workshop markers that the user can click to see details. This is where React's data-driven rendering shines. You'll define workshop data once, and React will turn each entry into a clickable map marker automatically.

In this step, get ready to:
  • Create a dataset of workshop locations.
  • Render interactive markers on the map from data.
  • Build a reusable WorkshopCard component with a Report Issue button.
Create the workshop sample data

Every marker on your map needs real data behind it. You'll create a dedicated data file that stores information about each workshop, including its GPS coordinates, speciality, and contact details.

  • Create a new folder called data inside your src folder.
  • Inside src/data, create a new file called workshops.js.
  • Add the following workshop data to workshops.js:
const workshops = [
  { id: 1, name: "Raju Tyre Works", lat: 17.390, lng: 78.490, speciality: "Tyre Repair", rating: 4.2, phone: "9876543210" },
  { id: 2, name: "Krishna Motors", lat: 17.385, lng: 78.495, speciality: "Engine", rating: 4.5, phone: "9876543211" },
  { id: 3, name: "Sai Auto Electrical", lat: 17.380, lng: 78.485, speciality: "Electrical", rating: 3.8, phone: "9876543212" },
  { id: 4, name: "Balaji Mechanics", lat: 17.392, lng: 78.480, speciality: "Engine", rating: 4.0, phone: "9876543213" },
  { id: 5, name: "Quick Fix Puncture", lat: 17.388, lng: 78.498, speciality: "Tyre Repair", rating: 4.7, phone: "9876543214" },
  { id: 6, name: "Highway Auto Care", lat: 17.395, lng: 78.488, speciality: "General Service", rating: 4.3, phone: "9876543215" },
]

export default workshops

What does this data represent?

Each object in the array represents one workshop on the map. The lat and lng fields are GPS coordinates that tell the map exactly where to place the marker.

The id field gives each workshop a unique identifier. React needs this to efficiently track each marker when the list changes.

  • Save src/data/workshops.js.

You won't see a change yet. The next substep uses this data to render markers on the map.

Render workshop markers with popups

Now you'll connect your workshop data to the map. React Leaflet provides Marker and Popup components that place clickable pins at specific coordinates.

  • In src/components/MapView.jsx, find the import line at the top that says:
import { MapContainer, TileLayer, Circle, useMap } from 'react-leaflet'
  • Replace that line with this updated import that adds Marker and Popup:
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet'
  • Below the existing import L from 'leaflet' line, add these two new imports:
import workshops from '../data/workshops'
import WorkshopCard from './WorkshopCard'
  • Inside the MapContainer, find the closing /> of the Circle component. Add this code directly below it:
      {workshops.map((workshop) => (
        <Marker key={workshop.id} position={[workshop.lat, workshop.lng]}>
          <Popup>
            <WorkshopCard workshop={workshop} onReportIssue={onReportIssue} />
          </Popup>
        </Marker>
      ))}

What does this code do?

The .map() method loops through your workshops array and creates a Marker component for each one. This is how React transforms data into UI elements.

The key={workshop.id} prop gives React a unique identifier for each marker. Without it, React can't tell which marker changed when data updates.

The Popup component renders content that appears when a user clicks a marker. You're passing a WorkshopCard component inside it, which you'll create next.

  • Save MapView.jsx.

Your browser will show an error because WorkshopCard doesn't exist yet. You'll create it in the next substep.

Create the WorkshopCard component

The WorkshopCard is a reusable component that displays workshop details inside each popup. It receives a workshop object as a prop and renders a clean card with a button to report issues.

  • Create a new file called WorkshopCard.jsx inside src/components/.
  • Add the following component code to WorkshopCard.jsx:
function WorkshopCard({ workshop, onReportIssue }) {
  return (
    <div className="workshop-card">
      <h3>{workshop.name}</h3>
      <p>Speciality: {workshop.speciality}</p>
      <p>Rating: {workshop.rating} / 5</p>
      <p>Phone: {workshop.phone}</p>
      <button onClick={() => onReportIssue(workshop)}>
        Report Issue
      </button>
    </div>
  )
}

export default WorkshopCard

What does this component do?

WorkshopCard receives two props: workshop (the data object) and onReportIssue (a callback function from the parent). Props are how React components receive data from their parents.

When the user clicks the Report Issue button, it calls onReportIssue(workshop). This sends the selected workshop data back up to App.jsx, which stores it in the selectedWorkshop state. You'll use that state in the next step to display a form.

The workshop card also needs some CSS to look good inside the small popup window.

  • Open src/index.css and add these styles at the bottom of the file:
.workshop-card {
  min-width: 200px;
}

.workshop-card h3 {
  margin: 0 0 4px 0;
  font-size: 1rem;
}

.workshop-card p {
  margin: 2px 0;
  font-size: 0.85rem;
  color: #555;
}

.workshop-card button {
  margin-top: 8px;
  padding: 6px 12px;
  background: #ff5722;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.85rem;
}

.workshop-card button:hover {
  background: #e64a19;
}

What do these styles do?

The min-width: 200px ensures the popup doesn't collapse too narrow. The button styles give the Report Issue button an orange color that signals action to the user.

  • Save both WorkshopCard.jsx and index.css.
  • Refresh your browser. You should see 6 blue marker pins on the map in addition to your blue circle location indicator.
  • Click any marker. A styled popup should appear showing the workshop name, speciality, rating, phone number, and an orange Report Issue button.

Don't see any markers?

Make sure you saved all three files: workshops.js, MapView.jsx, and WorkshopCard.jsx.

Check that your import path in MapView uses '../data/workshops' (two dots to go up one folder from components to src, then into data).

If your map is zoomed too far out, the markers may look clustered together. Zoom in around the Hyderabad area to spot them.

Why aren't my workshop markers showing on the map?

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

Here are the complete files for this step.

const workshops = [
  { id: 1, name: "Raju Tyre Works", lat: 17.390, lng: 78.490, speciality: "Tyre Repair", rating: 4.2, phone: "9876543210" },
  { id: 2, name: "Krishna Motors", lat: 17.385, lng: 78.495, speciality: "Engine", rating: 4.5, phone: "9876543211" },
  { id: 3, name: "Sai Auto Electrical", lat: 17.380, lng: 78.485, speciality: "Electrical", rating: 3.8, phone: "9876543212" },
  { id: 4, name: "Balaji Mechanics", lat: 17.392, lng: 78.480, speciality: "Engine", rating: 4.0, phone: "9876543213" },
  { id: 5, name: "Quick Fix Puncture", lat: 17.388, lng: 78.498, speciality: "Tyre Repair", rating: 4.7, phone: "9876543214" },
  { id: 6, name: "Highway Auto Care", lat: 17.395, lng: 78.488, speciality: "General Service", rating: 4.3, phone: "9876543215" },
]

export default workshops
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet'
import { useEffect } from 'react'
import L from 'leaflet'
import workshops from '../data/workshops'
import WorkshopCard from './WorkshopCard'

delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
  iconRetinaUrl: new URL('leaflet/dist/images/marker-icon-2x.png', import.meta.url).href,
  iconUrl: new URL('leaflet/dist/images/marker-icon.png', import.meta.url).href,
  shadowUrl: new URL('leaflet/dist/images/marker-shadow.png', import.meta.url).href,
})

function FlyToUser({ position }) {
  const map = useMap()
  useEffect(() => {
    if (position) {
      map.flyTo([position.lat, position.lng], 14)
    }
  }, [position, map])
  return null
}

function MapView({ userPosition, onReportIssue }) {
  return (
    <MapContainer
      center={[userPosition.lat, userPosition.lng]}
      zoom={14}
      style={{ height: '100%', width: '100%' }}
    >
      <TileLayer
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
      />
      <FlyToUser position={userPosition} />
      <Circle
        center={[userPosition.lat, userPosition.lng]}
        radius={50}
        pathOptions={{ color: '#1976d2', fillColor: '#1976d2', fillOpacity: 0.3 }}
      />
      {workshops.map((workshop) => (
        <Marker key={workshop.id} position={[workshop.lat, workshop.lng]}>
          <Popup>
            <WorkshopCard workshop={workshop} onReportIssue={onReportIssue} />
          </Popup>
        </Marker>
      ))}
    </MapContainer>
  )
}

export default MapView
function WorkshopCard({ workshop, onReportIssue }) {
  return (
    <div className="workshop-card">
      <h3>{workshop.name}</h3>
      <p>Speciality: {workshop.speciality}</p>
      <p>Rating: {workshop.rating} / 5</p>
      <p>Phone: {workshop.phone}</p>
      <button onClick={() => onReportIssue(workshop)}>
        Report Issue
      </button>
    </div>
  )
}

export default WorkshopCard
@import "leaflet/dist/leaflet.css";

body, #root {
  margin: 0;
  padding: 0;
  height: 100vh;
  font-family: system-ui, sans-serif;
}

.app-container {
  display: flex;
  height: 100vh;
  position: relative;
}

.map-wrapper {
  flex: 1;
  height: 100%;
}

.loading-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  font-size: 1.2rem;
  color: #555;
}

.error-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  font-size: 1.2rem;
  color: #d32f2f;
}

.workshop-card {
  min-width: 200px;
}

.workshop-card h3 {
  margin: 0 0 4px 0;
  font-size: 1rem;
}

.workshop-card p {
  margin: 2px 0;
  font-size: 0.85rem;
  color: #555;
}

.workshop-card button {
  margin-top: 8px;
  padding: 6px 12px;
  background: #ff5722;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.85rem;
}

.workshop-card button:hover {
  background: #e64a19;
}

Your map is now populated with clickable workshops. Next up, you'll build a form so users can actually report their vehicle issues and leave feedback for a workshop.

Add Issue Reporting and Feedback

Your map now shows workshop markers with detailed popups.

Knowing where a workshop is only solves half the problem. A traveler stranded with a flat tyre needs to actually tell the workshop what went wrong. In this step, you will build a form that lets users report their vehicle issue and leave feedback for a workshop.

In this step, get ready to:
  • Create an IssueForm component with controlled inputs.
  • Connect the form to the map using conditional rendering.
  • Verify the full issue reporting flow end-to-end.
Create the IssueForm component

The form needs to track what the user types in real time. In React, this pattern is called controlled components. Each input's value is stored in state, and every keystroke updates that state.

  • Create a new file src/components/IssueForm.jsx and paste the following code:
import { useState } from 'react'

function IssueForm({ workshop, onClose }) {
  const [vehicleType, setVehicleType] = useState('')
  const [issue, setIssue] = useState('')
  const [feedback, setFeedback] = useState('')

  function handleSubmit(event) {
    event.preventDefault()

    alert(
      `Issue reported to ${workshop.name}!\n` +
      `Vehicle: ${vehicleType}\n` +
      `Issue: ${issue}\n` +
      `Feedback: ${feedback}`
    )

    setVehicleType('')
    setIssue('')
    setFeedback('')
    onClose()
  }

  return (
    <form onSubmit={handleSubmit}>
      <h2>Report Issue - {workshop.name}</h2>
      <p style={{ color: '#666', fontSize: '0.9rem' }}>
        {workshop.speciality} | Rating: {workshop.rating}/5
      </p>

      <label htmlFor="vehicle-type">Vehicle Type</label>
      <select
        id="vehicle-type"
        value={vehicleType}
        onChange={(e) => setVehicleType(e.target.value)}
        required
      >
        <option value="">Select your vehicle</option>
        <option value="bike">Bike</option>
        <option value="car">Car</option>
        <option value="truck">Truck</option>
        <option value="auto">Auto Rickshaw</option>
      </select>

      <label htmlFor="issue-description">Describe the Issue</label>
      <textarea
        id="issue-description"
        value={issue}
        onChange={(e) => setIssue(e.target.value)}
        placeholder="e.g., Flat tyre on the rear left..."
        required
      />

      <label htmlFor="feedback">Your Feedback (optional)</label>
      <textarea
        id="feedback"
        value={feedback}
        onChange={(e) => setFeedback(e.target.value)}
        placeholder="Share your experience with this workshop..."
      />

      <button type="submit">Submit Report</button>
    </form>
  )
}

export default IssueForm

What does this code do?

  • Three useState calls create state variables for vehicleType, issue, and feedback. Each one remembers what the user has typed between renders.
  • The value and onChange pair on each input makes them "controlled". React owns the data, and the DOM just reflects it.
  • handleSubmit calls event.preventDefault() to stop the browser from reloading the page, shows a summary alert, resets all fields to empty, and calls onClose() so the parent component knows the form is done.

What are controlled components?

In a controlled component, React state is the single source of truth for the input value. The input doesn't store its own text. Instead, the component re-renders with the updated state on every keystroke.

This gives you full control over validation, formatting, and conditional logic based on what the user has typed so far.

  • Save src/components/IssueForm.jsx.

You won't see a change yet. The form exists but nothing renders it on screen. The next substep connects it to your app.

Integrate IssueForm into the workshop flow

The selectedWorkshop state in App.jsx already gets set when you click Report Issue on a marker. Now you need to render the IssueForm as a slide-in panel whenever that state has a value.

  • In src/App.jsx, add an import for IssueForm at the top. Find this line:
import MapView from './components/MapView'
  • Add this import directly below it:
import IssueForm from './components/IssueForm'
  • Now find the closing </div> of the app-container div. Add the following conditional rendering block between the closing </div> of map-wrapper and the closing </div> of app-container:
      {selectedWorkshop && (
        <div className="issue-overlay">
          <button className="close-btn" onClick={() => setSelectedWorkshop(null)}>
            &times;
          </button>
          <IssueForm
            workshop={selectedWorkshop}
            onClose={() => setSelectedWorkshop(null)}
          />
        </div>
      )}

What does this code do?

  • The {selectedWorkshop && (...)} pattern is conditional rendering. When selectedWorkshop is null, nothing renders. When it has a workshop object, the overlay panel appears.
  • The close button sets selectedWorkshop back to null, which hides the panel.
  • The onClose prop gives IssueForm a way to close itself after submission. This is the "callback prop" pattern for child-to-parent communication.

Why does the state live in App.jsx?

The Report Issue button lives inside MapView, but the overlay panel lives outside it. Both need access to selectedWorkshop. Moving the state up to their shared parent (App) lets both components communicate through it.

This is called "lifting state up" and it is one of the most common React patterns for sharing data between sibling components.

  • Save src/App.jsx.

The overlay won't look right yet because it needs CSS styles. The next chunk adds those.

  • In src/index.css, add the following styles at the bottom of the file (after the existing .workshop-card button:hover rule):
.issue-overlay {
  position: absolute;
  top: 0;
  right: 0;
  width: 360px;
  height: 100%;
  background: white;
  box-shadow: -2px 0 12px rgba(0, 0, 0, 0.15);
  z-index: 1000;
  overflow-y: auto;
  padding: 24px;
}

.issue-overlay h2 {
  margin-top: 0;
  font-size: 1.3rem;
}

.issue-overlay label {
  display: block;
  margin-top: 12px;
  font-weight: 600;
  font-size: 0.9rem;
}

.issue-overlay input,
.issue-overlay textarea,
.issue-overlay select {
  width: 100%;
  padding: 8px;
  margin-top: 4px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 0.95rem;
  box-sizing: border-box;
}

.issue-overlay textarea {
  resize: vertical;
  min-height: 80px;
}

.issue-overlay button {
  margin-top: 16px;
  padding: 10px 20px;
  background: #1976d2;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
}

.issue-overlay button:hover {
  background: #1565c0;
}

.close-btn {
  position: absolute;
  top: 12px;
  right: 12px;
  background: none;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
  color: #555;
}

What do these styles do?

  • The .issue-overlay positions the panel as a fixed 360px-wide sidebar anchored to the right edge of the screen.
  • z-index: 1000 ensures the panel sits above the map tiles.
  • The .close-btn is positioned absolutely in the top-right corner of the overlay so the user can dismiss it.
  • Save src/index.css and return to your browser.
  • Click any workshop marker on the map, then click Report Issue. A white panel should slide in from the right showing the form with the workshop name at the top.

Don't see the overlay panel?

  • Make sure you saved both App.jsx and index.css. The overlay needs both the JSX and the styles to render.
  • Check that the IssueForm import path matches your file location exactly: ./components/IssueForm.
  • If the panel appears but looks unstyled (no background, no width), confirm that the .issue-overlay class in your CSS matches the className="issue-overlay" in your JSX.

Help me debug why my issue overlay panel isn't appearing when I click Report Issue.

Verify the complete issue reporting flow

The form, the overlay, and the submit handler are all connected. Time to verify the full loop works from start to finish.

  • Click a workshop marker on the map.
  • In the popup, click Report Issue.
  • Select a vehicle type from the dropdown (e.g. Car).
  • Type a description in the issue textarea (e.g. Flat tyre on the rear left).
  • Type something in the feedback textarea (e.g. Good service last time).
  • Click Submit Report.

You should see a browser alert summarizing your report. After dismissing the alert, the panel closes and the form fields are cleared.

Why does the form reset after submit?

The handleSubmit function calls setVehicleType(''), setIssue(''), and setFeedback('') to reset every field to its initial empty state. Then onClose() sets selectedWorkshop back to null in App, hiding the overlay.

Because every input is controlled by state, resetting state is all you need. The inputs reflect the new empty values on the next render.

Form submits but the panel stays open?

  • Make sure onClose() is called at the end of handleSubmit in IssueForm.
  • Confirm that the onClose prop in App.jsx is () => setSelectedWorkshop(null). A typo like setSelectedWorkshop(none) would not clear the state.

Help me figure out why my issue form panel won't close after I submit.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for each file you changed or created in this step.

import { useState } from 'react'
import MapView from './components/MapView'
import IssueForm from './components/IssueForm'
import useGeolocation from './hooks/useGeolocation'

function App() {
  const { position, error } = useGeolocation()
  const [selectedWorkshop, setSelectedWorkshop] = useState(null)

  if (error) {
    return <div className="error-screen">Location error: {error}</div>
  }

  if (!position) {
    return <div className="loading-screen">Detecting your location...</div>
  }

  return (
    <div className="app-container">
      <div className="map-wrapper">
        <MapView
          userPosition={position}
          onReportIssue={setSelectedWorkshop}
        />
      </div>

      {selectedWorkshop && (
        <div className="issue-overlay">
          <button className="close-btn" onClick={() => setSelectedWorkshop(null)}>
            &times;
          </button>
          <IssueForm
            workshop={selectedWorkshop}
            onClose={() => setSelectedWorkshop(null)}
          />
        </div>
      )}
    </div>
  )
}

export default App
import { useState } from 'react'

function IssueForm({ workshop, onClose }) {
  const [vehicleType, setVehicleType] = useState('')
  const [issue, setIssue] = useState('')
  const [feedback, setFeedback] = useState('')

  function handleSubmit(event) {
    event.preventDefault()

    alert(
      `Issue reported to ${workshop.name}!\n` +
      `Vehicle: ${vehicleType}\n` +
      `Issue: ${issue}\n` +
      `Feedback: ${feedback}`
    )

    setVehicleType('')
    setIssue('')
    setFeedback('')
    onClose()
  }

  return (
    <form onSubmit={handleSubmit}>
      <h2>Report Issue - {workshop.name}</h2>
      <p style={{ color: '#666', fontSize: '0.9rem' }}>
        {workshop.speciality} | Rating: {workshop.rating}/5
      </p>

      <label htmlFor="vehicle-type">Vehicle Type</label>
      <select
        id="vehicle-type"
        value={vehicleType}
        onChange={(e) => setVehicleType(e.target.value)}
        required
      >
        <option value="">Select your vehicle</option>
        <option value="bike">Bike</option>
        <option value="car">Car</option>
        <option value="truck">Truck</option>
        <option value="auto">Auto Rickshaw</option>
      </select>

      <label htmlFor="issue-description">Describe the Issue</label>
      <textarea
        id="issue-description"
        value={issue}
        onChange={(e) => setIssue(e.target.value)}
        placeholder="e.g., Flat tyre on the rear left..."
        required
      />

      <label htmlFor="feedback">Your Feedback (optional)</label>
      <textarea
        id="feedback"
        value={feedback}
        onChange={(e) => setFeedback(e.target.value)}
        placeholder="Share your experience with this workshop..."
      />

      <button type="submit">Submit Report</button>
    </form>
  )
}

export default IssueForm
@import "leaflet/dist/leaflet.css";

body, #root {
  margin: 0;
  padding: 0;
  height: 100vh;
  font-family: system-ui, sans-serif;
}

.app-container {
  display: flex;
  height: 100vh;
  position: relative;
}

.map-wrapper {
  flex: 1;
  height: 100%;
}

.loading-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  font-size: 1.2rem;
  color: #555;
}

.error-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  font-size: 1.2rem;
  color: #d32f2f;
}

.issue-overlay {
  position: absolute;
  top: 0;
  right: 0;
  width: 360px;
  height: 100%;
  background: white;
  box-shadow: -2px 0 12px rgba(0, 0, 0, 0.15);
  z-index: 1000;
  overflow-y: auto;
  padding: 24px;
}

.issue-overlay h2 {
  margin-top: 0;
  font-size: 1.3rem;
}

.issue-overlay label {
  display: block;
  margin-top: 12px;
  font-weight: 600;
  font-size: 0.9rem;
}

.issue-overlay input,
.issue-overlay textarea,
.issue-overlay select {
  width: 100%;
  padding: 8px;
  margin-top: 4px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 0.95rem;
  box-sizing: border-box;
}

.issue-overlay textarea {
  resize: vertical;
  min-height: 80px;
}

.issue-overlay button {
  margin-top: 16px;
  padding: 10px 20px;
  background: #1976d2;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
}

.issue-overlay button:hover {
  background: #1565c0;
}

.close-btn {
  position: absolute;
  top: 12px;
  right: 12px;
  background: none;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
  color: #555;
}

.workshop-card {
  min-width: 200px;
}

.workshop-card h3 {
  margin: 0 0 4px 0;
  font-size: 1rem;
}

.workshop-card p {
  margin: 2px 0;
  font-size: 0.85rem;
  color: #555;
}

.workshop-card button {
  margin-top: 8px;
  padding: 6px 12px;
  background: #ff5722;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.85rem;
}

.workshop-card button:hover {
  background: #e64a19;
}

The full issue reporting loop is working. A user can find a workshop, report their vehicle problem, leave feedback, and the app resets cleanly for the next interaction. In a real app you would send this data to a server, but the React patterns are identical.

Secret mission

Add Star Ratings and a Workshop List Panel

Your Workshop Finder works, but real apps let users rate their experience and browse options without clicking every marker. In this mission, you'll build a reusable StarRating component from scratch and add a filterable sidebar that lists all workshops.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The workshop-finder project folder on your Desktop.
  • The Vite dev server (running process in your terminal).
  • Node.js packages installed inside node_modules.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep experimenting with your Workshop Finder app.

  • Your project folder stays on your Desktop.
  • You can restart the dev server anytime by opening a terminal, navigating to workshop-finder, and running npm run dev.

Pause - I'll come back to this later

Shut down the running dev server to free up your terminal. Your project files remain on disk for later.

  • Switch to the terminal where the Vite dev server is running.
  • Press Ctrl+C to stop the server.
  • Your workshop-finder folder stays on your Desktop. You can resume by running npm run dev inside that folder.

Delete - I don't want to use this again

Remove all project files and start fresh. This cannot be undone.

  • Switch to the terminal where the Vite dev server is running and press Ctrl+C to stop it.
  • Delete the project folder by running:

macOS

cd ~/Desktop
rm -rf workshop-finder

Windows

cd ~\Desktop
rmdir /s /q workshop-finder
  • Confirm the folder is gone by running:
ls

You should no longer see workshop-finder in the output.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully interactive Workshop Finder app that detects your real GPS location and displays nearby workshops on a live map.

You've learned how to:

  • Built a location-aware React app from scratch using Vite, with reusable components (MapView, WorkshopCard, IssueForm) connected through props and composition.
  • Used useState, useEffect, and a custom useGeolocation hook to manage dynamic data and read real GPS coordinates from the Browser Geolocation API.
  • Rendered an interactive map with React Leaflet, including custom markers, popups, and controlled forms for issue reporting and feedback.
  • Secret Mission: Created a reusable StarRating component and a filterable WorkshopList panel that flies the map to selected workshops.

Ready to quiz yourself?