feat: map feature added

This commit is contained in:
Afra ‌ 2026-07-15 14:05:20 +03:30
parent 6dab359bae
commit c2d87e636c
5 changed files with 162 additions and 51 deletions

View file

@ -11,6 +11,7 @@ import AddEvent from './pages/AddEvent'
import AddEventSuccess from './pages/AddEventSuccess'
import AddEventFail from './pages/AddEventFail'
import EventReservations from './pages/EventReservations'
import CafeMapPage from './pages/CafeMapPage'
import MyReservations from './pages/MyReservations'
import Login from './pages/Login'
import LoginOtp from './pages/LoginOtp'
@ -35,6 +36,7 @@ export default function App() {
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/home" element={<RequireRole role="customer"><Home /></RequireRole>} />
<Route path="/cafe/:id" element={<RequireRole role="customer"><CafeDetail /></RequireRole>} />
<Route path="/map" element={<RequireRole role="customer"><CafeMapPage /></RequireRole>} />
<Route path="/payment/success" element={<RequireRole role="customer"><PaymentSuccess /></RequireRole>} />
<Route path="/reservations" element={<RequireRole role="customer"><MyReservations /></RequireRole>} />
<Route path="/owner/register" element={<RequireRole role="cafe_owner"><CafeOwnerRegister /></RequireRole>} />

72
src/pages/CafeMapPage.tsx Normal file
View file

@ -0,0 +1,72 @@
import { useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { MapContainer, TileLayer, Marker, ZoomControl, useMap } from 'react-leaflet'
import 'leaflet/dist/leaflet.css'
import '../utils/leafletIcons'
import { Cafe } from '../services/campaignApi'
import Icon from '../components/Icon'
function FitBounds({ cafes }: { cafes: Cafe[] }) {
const map = useMap()
useEffect(() => {
if (cafes.length === 0) return
if (cafes.length === 1) {
const [lng, lat] = cafes[0].location.coordinates
map.setView([lat, lng], 15)
return
}
const bounds = cafes.map(
(c) => [c.location.coordinates[1], c.location.coordinates[0]] as [number, number],
)
map.fitBounds(bounds, { padding: [60, 60] })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return null
}
export default function CafeMapPage() {
const navigate = useNavigate()
const location = useLocation()
const cafes = (location.state as { cafes: Cafe[] } | null)?.cafes ?? []
const defaultCenter: [number, number] =
cafes.length > 0
? [cafes[0].location.coordinates[1], cafes[0].location.coordinates[0]]
: [35.6892, 51.389]
return (
<div className="relative h-screen w-screen">
<button
type="button"
onClick={() => navigate(-1)}
className="absolute right-4 top-4 z-[1000] flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-lg"
>
<Icon name="arrowRight" className="h-5 w-5 text-gray-700" />
</button>
<MapContainer
center={defaultCenter}
zoom={13}
scrollWheelZoom
zoomControl={false}
className="h-full w-full"
>
<TileLayer
attribution="&copy; Addwin Maps"
url="https://maps.addwin.ir/styles/basic/{z}/{x}/{y}.png"
maxZoom={19}
minZoom={8}
/>
<ZoomControl position="topleft" />
<FitBounds cafes={cafes} />
{cafes.map((cafe) => (
<Marker
key={cafe.uuid}
position={[cafe.location.coordinates[1], cafe.location.coordinates[0]]}
eventHandlers={{ click: () => navigate(`/cafe/${cafe.uuid}`, { state: { cafe } }) }}
/>
))}
</MapContainer>
</div>
)
}

View file

@ -1,5 +1,8 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { MapContainer, TileLayer, Marker } from 'react-leaflet'
import 'leaflet/dist/leaflet.css'
import '../utils/leafletIcons'
import Icon from '../components/Icon'
import HomeBanner from '../components/HomeBanner'
import { Cafe, City, fetchCafes, fetchCities, MatchEvent } from '../services/campaignApi'
@ -11,45 +14,75 @@ function findTehran(cities: City[]): City | undefined {
return cities.find((c) => c.city_name === 'تهران' || c.city_name.toLowerCase() === 'tehran')
}
// MapIcon/ListIcon — only used by the disabled نقشه/لیست toggle above, commented out together with it
// function MapIcon() {
// return (
// <svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
// <path
// d="M1.52734 5.186V11.6727C1.52734 12.9393 2.42734 13.4593 3.52068 12.8327L5.08734 11.9393C5.42734 11.746 5.99401 11.726 6.34734 11.906L9.84734 13.6593C10.2007 13.8327 10.7673 13.8193 11.1073 13.626L13.994 11.9727C14.3607 11.7593 14.6673 11.2393 14.6673 10.8127V4.326C14.6673 3.05933 13.7673 2.53933 12.674 3.166L11.1073 4.05933C10.7673 4.25267 10.2007 4.27267 9.84734 4.09267L6.34734 2.346C5.99401 2.17267 5.42734 2.186 5.08734 2.37933L2.20068 4.03267C1.82734 4.246 1.52734 4.766 1.52734 5.186Z"
// stroke="currentColor"
// strokeWidth={1.25}
// strokeLinecap="round"
// strokeLinejoin="round"
// />
// <path d="M5.70703 2.66602V11.3327" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// <path d="M10.4863 4.41406V13.3341" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// </svg>
// )
// }
//
// function ListIcon() {
// return (
// <svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
// <path d="M7.33398 13H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// <path d="M7.33398 8.33398H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// <path d="M7.33398 3.66602H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// <path d="M2 3.66732L2.66667 4.33398L4.66667 2.33398" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// <path d="M2 8.33333L2.66667 9L4.66667 7" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// <path d="M2 12.9993L2.66667 13.666L4.66667 11.666" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
// </svg>
// )
// }
function MapIcon() {
return (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.52734 5.186V11.6727C1.52734 12.9393 2.42734 13.4593 3.52068 12.8327L5.08734 11.9393C5.42734 11.746 5.99401 11.726 6.34734 11.906L9.84734 13.6593C10.2007 13.8327 10.7673 13.8193 11.1073 13.626L13.994 11.9727C14.3607 11.7593 14.6673 11.2393 14.6673 10.8127V4.326C14.6673 3.05933 13.7673 2.53933 12.674 3.166L11.1073 4.05933C10.7673 4.25267 10.2007 4.27267 9.84734 4.09267L6.34734 2.346C5.99401 2.17267 5.42734 2.186 5.08734 2.37933L2.20068 4.03267C1.82734 4.246 1.52734 4.766 1.52734 5.186Z"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M5.70703 2.66602V11.3327" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M10.4863 4.41406V13.3341" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
// MapPlaceholder — only used by the disabled نقشه/لیست toggle below (the cafes-on-a-map
// view isn't built yet); the top-of-page بنر slot it used to fill is now HomeBanner.
// function MapPlaceholder() {
// return (
// <div className="w-full h-[35vh] bg-gray-200 flex items-center justify-center text-gray-500">
// بنر
// </div>
// )
// }
function ListIcon() {
return (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.33398 13H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M7.33398 8.33398H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M7.33398 3.66602H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M2 3.66732L2.66667 4.33398L4.66667 2.33398" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M2 8.33333L2.66667 9L4.66667 7" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M2 12.9993L2.66667 13.666L4.66667 11.666" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function CafesMapPreview({ cafes }: { cafes: Cafe[] }) {
const navigate = useNavigate()
const center: [number, number] =
cafes.length > 0
? [cafes[0].location.coordinates[1], cafes[0].location.coordinates[0]]
: [35.6892, 51.389]
return (
<div className="relative mx-4 mt-3 h-52 overflow-hidden rounded-2xl">
<MapContainer
center={center}
zoom={12}
scrollWheelZoom={false}
dragging={false}
zoomControl={false}
doubleClickZoom={false}
keyboard={false}
className="h-full w-full"
>
<TileLayer
attribution="&copy; Addwin Maps"
url="https://maps.addwin.ir/styles/basic/{z}/{x}/{y}.png"
maxZoom={19}
minZoom={8}
/>
{cafes.map((cafe) => (
<Marker
key={cafe.uuid}
position={[cafe.location.coordinates[1], cafe.location.coordinates[0]]}
/>
))}
</MapContainer>
{/* transparent overlay — captures all clicks and opens the full-page map */}
<div
className="absolute inset-0 z-[1000] cursor-pointer"
onClick={() => navigate('/map', { state: { cafes } })}
/>
</div>
)
}
function CitySelectorRow({
label,
@ -205,9 +238,7 @@ function CafeCard({ cafe }: { cafe: Cafe }) {
}
export default function Home() {
// نقشه/لیست toggle — disabled for now, the map view isn't part of the app yet
// (will be added in a later version); uncomment together with its JSX below
// const [view, setView] = useState<'list' | 'map'>('list')
const [view, setView] = useState<'list' | 'map'>('list')
const [cities, setCities] = useState<City[]>([])
const [selectedCityUuid, setSelectedCityUuid] = useState('')
const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
@ -252,7 +283,6 @@ export default function Home() {
<div className="mt-6 flex items-center justify-between px-4">
<h2 className="font-semibold text-gray-900">کافه‌ها</h2>
{/* نقشه/لیست toggle — commented out together with the `view` state above, see that comment
<div className="flex rounded-full bg-gray-100 p-1">
<button
type="button"
@ -275,17 +305,19 @@ export default function Home() {
لیست
</button>
</div>
*/}
</div>
{view === 'map' ? (
<CafesMapPreview cafes={cafes} />
) : (
<div className="mt-3 px-4">
{/* always the list view for now — restore the `view === 'list' ? ... : <MapPlaceholder />` ternary when the map toggle above is restored */}
<div className="space-y-3">
{cafes.map((cafe) => (
<CafeCard key={cafe.uuid} cafe={cafe} />
))}
</div>
</div>
)}
<button
type="button"

View file

@ -4,7 +4,7 @@ import Icon from '../components/Icon'
import { requestOtp, verifyOtp } from '../services/authApi'
import { fetchMyCafes } from '../services/campaignApi'
import { storeAuthTokens } from '../utils/authToken'
import { setCafeUuid } from '../utils/cafe'
import { clearCafeUuid, setCafeUuid } from '../utils/cafe'
import { setUserPhone } from '../utils/userPhone'
import { toEnglishDigits, toPersianDigits } from '../utils/formatNumber'
import { toE164 } from '../utils/phone'
@ -159,6 +159,7 @@ export default function LoginOtp() {
setCafeUuid(myCafes[0].uuid)
navigate('/owner/panel')
} else {
clearCafeUuid()
navigate('/owner/register')
}
} else {

View file

@ -7,3 +7,7 @@ export function setCafeUuid(uuid: string) {
export function getCafeUuid(): string | null {
return localStorage.getItem(CAFE_UUID_STORAGE_KEY)
}
export function clearCafeUuid() {
localStorage.removeItem(CAFE_UUID_STORAGE_KEY)
}