feat(lib): enhanced LocateControl (#308)

* improved locate control

* fix(lib): clean up setTimeout and fix Chrome modal layout issue

- Add proper cleanup for setTimeout in LocateControl to prevent memory leaks
- Replace modal-open class with direct overflow style to fix Chrome scrollbar issue
- Add timeout reference tracking for better component unmount handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix linting

* set distance rule

* optimized locatecontrol

* working for new users without profile now

* test(lib): add comprehensive tests for LocateControl component

- Add 9 comprehensive unit tests covering all LocateControl functionality
- Test modal display logic for new and existing users
- Test profile creation and position updates
- Test navigation after successful operations
- Test error handling with proper toast notifications
- Mock all external dependencies (React Router, Leaflet, APIs)
- Verify dialog behavior prevents re-appearance after decline
- Include snapshot tests for UI consistency
- All tests pass with proper TypeScript typing

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* removed unused code, add 1s delay

* updated tests

* fixed tests

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Anton Tranelis 2025-08-20 16:39:19 +02:00 committed by GitHub
parent 649efe551d
commit f7f758bb00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 882 additions and 30 deletions

View File

@ -122,4 +122,10 @@ Uses **Directus** as headless CMS with:
- **TypeScript strict mode** ensures type safety - **TypeScript strict mode** ensures type safety
- Pre-commit hooks run linting checks via `scripts/check-lint.sh` - Pre-commit hooks run linting checks via `scripts/check-lint.sh`
- Coverage reporting for unit tests - Coverage reporting for unit tests
- Automated dependency updates via `npm-check-updates` - Automated dependency updates via `npm-check-updates`
## CSS and Styling Conventions
- **Tailwind CSS Prefix**: Always use the `tw:` prefix for all Tailwind CSS classes (e.g., `tw:flex`, `tw:bg-base-100`)
- **DaisyUI Components**: Use the `tw:` prefix for all DaisyUI component classes (e.g., `tw:btn`, `tw:card`, `tw:modal`)
- This prefix system prevents conflicts with other CSS frameworks and maintains consistent styling across the codebase

View File

@ -0,0 +1,484 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen, fireEvent, act } from '@testing-library/react'
import { MapContainer } from 'react-leaflet'
import { MemoryRouter } from 'react-router-dom'
import { toast } from 'react-toastify'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { LocateControl } from './LocateControl'
import type { Item } from '#types/Item'
import type { ItemsApi } from '#types/ItemsApi'
import type { ItemType } from '#types/ItemType'
import type { LayerProps as Layer } from '#types/LayerProps'
import type { MarkerIcon } from '#types/MarkerIcon'
import type { Mock } from 'vitest'
interface User {
id: string
first_name: string
last_name: string
email: string
}
// Mock external dependencies
vi.mock('react-toastify', () => ({
toast: {
loading: vi.fn(() => 'toast-id'),
update: vi.fn(),
success: vi.fn(),
error: vi.fn(),
},
}))
vi.mock('react-leaflet', async () => {
const actual = await vi.importActual('react-leaflet')
return {
...actual,
useMap: vi.fn(() => ({
closePopup: vi.fn(),
})),
useMapEvents: vi.fn((eventHandlers) => {
;(global as any).mockMapEventHandlers = eventHandlers
return null
}),
}
})
vi.mock('leaflet', () => ({
control: {
locate: vi.fn(() => ({
addTo: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
})),
},
}))
vi.mock('#components/Auth/useAuth')
vi.mock('#components/Map/hooks/useMyProfile')
vi.mock('#components/Map/hooks/useItems')
vi.mock('#components/Map/hooks/useLayers')
const mockNavigate = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom')
return {
...actual,
useNavigate: () => mockNavigate,
}
})
const mockUser: User = {
id: 'user-1',
first_name: 'John',
last_name: 'Doe',
email: 'john@example.com',
}
const mockApi: ItemsApi<Item> = {
getItems: vi.fn().mockResolvedValue([]),
getItem: vi.fn(),
createItem: vi.fn(),
updateItem: vi.fn(),
deleteItem: vi.fn(),
collectionName: 'test-collection',
}
const mockMarkerIcon: MarkerIcon = {
image: 'test-icon.svg',
size: 32,
}
const mockItemType: ItemType = {
name: 'user',
show_name_input: true,
show_profile_button: true,
show_start_end: false,
show_start_end_input: false,
show_text: true,
show_text_input: true,
custom_text: '',
profileTemplate: [],
offers_and_needs: false,
icon_as_labels: false,
relations: false,
template: 'simple',
questlog: false,
}
const mockLayer: Layer = {
id: 'layer-1',
name: 'Users',
menuIcon: 'user',
menuColor: '#ff0000',
menuText: 'Users',
markerIcon: mockMarkerIcon,
markerShape: 'circle',
markerDefaultColor: '#ff0000',
itemType: mockItemType,
userProfileLayer: true,
api: mockApi,
}
const mockProfile: Item = {
id: 'profile-1',
name: 'John Doe',
position: {
type: 'Point',
coordinates: [10.0, 50.0],
},
user_created: mockUser,
layer: mockLayer,
}
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MemoryRouter>
<MapContainer center={[51.505, -0.09]} zoom={13}>
{children}
</MapContainer>
</MemoryRouter>
)
describe('<LocateControl />', () => {
let mockUseAuth: Mock
let mockUseMyProfile: Mock
let mockUseUpdateItem: Mock
let mockUseAddItem: Mock
let mockUseLayers: Mock
beforeEach(async () => {
vi.clearAllMocks()
vi.useFakeTimers()
mockNavigate.mockClear()
;(global as any).mockMapEventHandlers = {}
const { useAuth } = await import('#components/Auth/useAuth')
const { useMyProfile } = await import('#components/Map/hooks/useMyProfile')
const { useUpdateItem, useAddItem } = await import('#components/Map/hooks/useItems')
const { useLayers } = await import('#components/Map/hooks/useLayers')
mockUseAuth = vi.mocked(useAuth)
mockUseMyProfile = vi.mocked(useMyProfile)
mockUseUpdateItem = vi.mocked(useUpdateItem)
mockUseAddItem = vi.mocked(useAddItem)
mockUseLayers = vi.mocked(useLayers)
mockUseAuth.mockReturnValue({ user: mockUser })
mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true })
mockUseUpdateItem.mockReturnValue(vi.fn())
mockUseAddItem.mockReturnValue(vi.fn())
mockUseLayers.mockReturnValue([mockLayer])
})
afterEach(() => {
vi.useRealTimers()
})
describe('Component Rendering', () => {
it('renders the locate control button', () => {
render(
<TestWrapper>
<LocateControl />
</TestWrapper>,
)
const button = screen.getByRole('button', { name: /start location tracking/i })
expect(button).toBeInTheDocument()
})
it('displays target icon when not active', () => {
render(
<TestWrapper>
<LocateControl />
</TestWrapper>,
)
const button = screen.getByRole('button', { name: /start location tracking/i })
expect(button).toBeInTheDocument()
expect(button.querySelector('svg')).toBeInTheDocument()
})
it('matches snapshot', () => {
const { container } = render(
<TestWrapper>
<LocateControl />
</TestWrapper>,
)
expect(container.firstChild).toMatchSnapshot()
})
})
describe('Modal Display Logic', () => {
it('shows modal for new user without profile when location is found', () => {
mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true })
render(
<TestWrapper>
<LocateControl />
</TestWrapper>,
)
const locationEvent = {
latlng: { lat: 52.5, lng: 13.4, distanceTo: vi.fn(() => 200) },
}
act(() => {
;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent)
})
act(() => {
vi.runAllTimers()
})
// Check if modal appears after timeout
expect(screen.getByText(/create your profile at your current location/i)).toBeInTheDocument()
})
it('shows modal for existing user when location is far from current position', () => {
const profileWithPosition = {
...mockProfile,
position: {
type: 'Point' as const,
coordinates: [10.0, 50.0],
},
}
mockUseMyProfile.mockReturnValue({ myProfile: profileWithPosition, isMyProfileLoaded: true })
render(
<TestWrapper>
<LocateControl />
</TestWrapper>,
)
const locationEvent = {
latlng: {
lat: 52.5,
lng: 13.4,
distanceTo: vi.fn(() => 200),
},
}
act(() => {
;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent)
})
act(() => {
vi.runAllTimers()
})
// Check if modal appears after timeout
expect(screen.getByText(/place your profile at your current location/i)).toBeInTheDocument()
})
})
describe('Profile Creation', () => {
it('creates new profile when user has no existing profile', async () => {
const mockCreateItem = vi.fn().mockResolvedValue({
id: 'new-profile-1',
name: 'John',
position: { type: 'Point', coordinates: [13.4, 52.5] },
})
const mockAddItem = vi.fn()
mockApi.createItem = mockCreateItem
mockUseAddItem.mockReturnValue(mockAddItem)
mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true })
render(
<TestWrapper>
<LocateControl />
</TestWrapper>,
)
const locationEvent = {
latlng: { lat: 52.5, lng: 13.4, distanceTo: vi.fn(() => 200) },
}
act(() => {
;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent)
})
act(() => {
vi.runAllTimers()
})
// Check if modal appears after timeout
expect(screen.getByText(/create your profile/i)).toBeInTheDocument()
const yesButton = screen.getByText('Yes')
await act(async () => {
fireEvent.click(yesButton)
// Allow promises to resolve
await vi.runAllTimersAsync()
})
// Verify API calls were made
expect(mockCreateItem).toHaveBeenCalled()
expect(mockAddItem).toHaveBeenCalled()
expect(toast.loading).toHaveBeenCalledWith('Creating profile at location')
})
it('updates existing profile position', async () => {
const mockUpdateItem = vi.fn().mockResolvedValue({
id: 'profile-1',
position: { type: 'Point', coordinates: [13.4, 52.5] },
})
const mockUpdateItemHook = vi.fn()
// Create a profile with a current position far from the new location
const profileWithCurrentPosition = {
...mockProfile,
position: {
type: 'Point' as const,
coordinates: [10.0, 50.0], // lng, lat format - far from 13.4, 52.5
},
}
if (profileWithCurrentPosition.layer?.api) {
profileWithCurrentPosition.layer.api.updateItem = mockUpdateItem
}
mockUseUpdateItem.mockReturnValue(mockUpdateItemHook)
mockUseMyProfile.mockReturnValue({
myProfile: profileWithCurrentPosition,
isMyProfileLoaded: true,
})
render(
<TestWrapper>
<LocateControl />
</TestWrapper>,
)
// Mock distanceTo to return a distance > 100m
const mockDistanceTo = vi.fn(() => 200)
const locationEvent = {
latlng: { lat: 52.5, lng: 13.4, distanceTo: mockDistanceTo },
}
act(() => {
;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent)
})
// Verify distanceTo was called with swapped coordinates [lat, lng]
// Verify distanceTo was called with swapped coordinates [lat, lng]
expect(mockDistanceTo).toHaveBeenCalledWith([50.0, 10.0])
act(() => {
vi.runAllTimers()
})
// Check if modal appears after timeout
expect(screen.getByText(/place your profile/i)).toBeInTheDocument()
// Find the Yes button by text content instead of role
const yesButton = screen.getByText('Yes')
await act(async () => {
fireEvent.click(yesButton)
// Allow promises to resolve
await vi.runAllTimersAsync()
})
// Verify API calls were made
expect(mockUpdateItem).toHaveBeenCalled()
expect(mockUpdateItemHook).toHaveBeenCalled()
expect(toast.loading).toHaveBeenCalledWith('Updating position')
})
})
describe('Navigation', () => {
it('navigates to profile after successful creation', async () => {
const mockCreateItem = vi.fn().mockResolvedValue({
id: 'new-profile-1',
name: 'John',
position: { type: 'Point', coordinates: [13.4, 52.5] },
})
mockApi.createItem = mockCreateItem
mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true })
render(
<TestWrapper>
<LocateControl />
</TestWrapper>,
)
const locationEvent = {
latlng: { lat: 52.5, lng: 13.4, distanceTo: vi.fn(() => 200) },
}
act(() => {
;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent)
})
act(() => {
vi.runAllTimers()
})
// Check if modal appears after timeout
expect(screen.getByText(/create your profile/i)).toBeInTheDocument()
const yesButton = screen.getByText('Yes')
await act(async () => {
fireEvent.click(yesButton)
// Allow promises to resolve
await vi.runAllTimersAsync()
})
// Verify navigation was called
expect(mockNavigate).toHaveBeenCalledWith('/new-profile-1')
})
})
describe('Error Handling', () => {
it('handles API errors gracefully', async () => {
const mockCreateItem = vi.fn().mockRejectedValue(new Error('Network error'))
mockApi.createItem = mockCreateItem
mockUseMyProfile.mockReturnValue({ myProfile: null, isMyProfileLoaded: true })
render(
<TestWrapper>
<LocateControl />
</TestWrapper>,
)
const locationEvent = {
latlng: { lat: 52.5, lng: 13.4, distanceTo: vi.fn(() => 200) },
}
act(() => {
;(global as any).mockMapEventHandlers?.locationfound?.(locationEvent)
})
act(() => {
vi.runAllTimers()
})
// Check if modal appears after timeout
expect(screen.getByText(/create your profile/i)).toBeInTheDocument()
const yesButton = screen.getByText('Yes')
await act(async () => {
fireEvent.click(yesButton)
// Allow promises to resolve
await vi.runAllTimersAsync()
})
// Verify error toast was shown
expect(toast.update).toHaveBeenCalledWith('toast-id', {
render: 'Network error',
type: 'error',
isLoading: false,
autoClose: 5000,
closeButton: true,
})
})
})
})

View File

@ -1,72 +1,297 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/prefer-ts-expect-error */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { control } from 'leaflet' import { control } from 'leaflet'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import SVG from 'react-inlinesvg' import SVG from 'react-inlinesvg'
import { useMap, useMapEvents } from 'react-leaflet' import { useMap, useMapEvents } from 'react-leaflet'
import { useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
import TargetSVG from '#assets/target.svg' import TargetSVG from '#assets/target.svg'
import { useAuth } from '#components/Auth/useAuth'
import { useAddItem, useUpdateItem } from '#components/Map/hooks/useItems'
import { useLayers } from '#components/Map/hooks/useLayers'
import { useMyProfile } from '#components/Map/hooks/useMyProfile'
import DialogModal from '#components/Templates/DialogModal'
import type { Item } from '#types/Item'
import type { LatLng } from 'leaflet'
// eslint-disable-next-line import/no-unassigned-import // eslint-disable-next-line import/no-unassigned-import
import 'leaflet.locatecontrol' import 'leaflet.locatecontrol'
// Converts leaflet.locatecontrol to a React Component // Type definitions for leaflet.locatecontrol
export const LocateControl = () => { declare module 'leaflet' {
const map = useMap() // eslint-disable-next-line @typescript-eslint/no-namespace
namespace control {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function locate(options?: object): any
}
}
// prevent react18 from calling useEffect twice /**
* React wrapper for leaflet.locatecontrol that provides user geolocation functionality
* @category Map Controls
*/
export const LocateControl = (): JSX.Element => {
const map = useMap()
const myProfile = useMyProfile()
const updateItem = useUpdateItem()
const addItem = useAddItem()
const layers = useLayers()
const { user } = useAuth()
const navigate = useNavigate()
// Prevent React 18 StrictMode from calling useEffect twice
const init = useRef(false) const init = useRef(false)
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
const [lc, setLc] = useState<any>(null) const [lc, setLc] = useState<any>(null)
const [active, setActive] = useState<boolean>(false) const [active, setActive] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(false) const [loading, setLoading] = useState<boolean>(false)
const [showLocationModal, setShowLocationModal] = useState<boolean>(false)
const [foundLocation, setFoundLocation] = useState<LatLng | null>(null)
const [hasUpdatedPosition, setHasUpdatedPosition] = useState<boolean>(false)
const [hasDeclinedModal, setHasDeclinedModal] = useState<boolean>(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const currentPosition = myProfile.myProfile?.position?.coordinates ?? null
// Determine if modal should be shown based on distance and conditions
const shouldShowModal = useCallback(
(targetLocation: LatLng | null, hasUpdated: boolean): boolean => {
if (!targetLocation || hasUpdated || hasDeclinedModal || !user) return false
// Show modal if user has no profile (new user)
if (!myProfile.myProfile) return true
// Show modal if user has no current position
if (!currentPosition) return true
const distance = targetLocation.distanceTo([currentPosition[1], currentPosition[0]])
return distance >= 100
},
[myProfile.myProfile, currentPosition, hasDeclinedModal, user],
)
useEffect(() => { useEffect(() => {
if (!init.current) { if (!init.current) {
// @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
setLc(control.locate().addTo(map)) setLc(control.locate().addTo(map))
init.current = true init.current = true
} }
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// Check if user logged in while location is active and found
useEffect(() => {
if (
active &&
foundLocation &&
!showLocationModal &&
shouldShowModal(foundLocation, hasUpdatedPosition)
) {
timeoutRef.current = setTimeout(() => {
setShowLocationModal(true)
}, 1000)
}
}, [active, foundLocation, showLocationModal, hasUpdatedPosition, shouldShowModal])
useMapEvents({ useMapEvents({
locationfound: () => { locationfound: (e) => {
setLoading(false) setLoading(false)
setActive(true) setActive(true)
setFoundLocation(e.latlng)
},
locationerror: () => {
setLoading(false)
setActive(false)
}, },
}) })
const handleLocateClick = (): void => {
if (!lc) return
if (active) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
lc.stop()
setActive(false)
setHasDeclinedModal(false) // Reset declined state when turning off location
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
lc.start()
setLoading(true)
setHasDeclinedModal(false) // Reset declined state when turning on location
}
}
const itemUpdatePosition = useCallback(async () => {
if (!foundLocation || !user) return
const toastId = toast.loading(
myProfile.myProfile ? 'Updating position' : 'Creating profile at location',
)
try {
let result: Item
if (myProfile.myProfile) {
// Update existing profile
const updatedProfile = {
id: myProfile.myProfile.id,
position: { type: 'Point', coordinates: [foundLocation.lng, foundLocation.lat] },
}
if (!myProfile.myProfile.layer?.api?.updateItem) {
throw new Error('Update API not available')
}
result = await myProfile.myProfile.layer.api.updateItem(updatedProfile as Item)
// Use server response for local state update
updateItem({ ...result, layer: myProfile.myProfile.layer })
toast.update(toastId, {
render: 'Position updated',
type: 'success',
isLoading: false,
autoClose: 5000,
closeButton: true,
})
} else {
// Create new profile
const userLayer = layers.find((l) => l.userProfileLayer === true)
if (!userLayer?.api?.createItem) {
throw new Error('User profile layer or create API not available')
}
const newProfile = {
id: crypto.randomUUID(),
name: user.first_name ?? 'User',
position: { type: 'Point', coordinates: [foundLocation.lng, foundLocation.lat] },
}
result = await userLayer.api.createItem(newProfile as Item)
// Use server response for local state update
addItem({
...result,
user_created: user,
layer: userLayer,
public_edit: false,
})
toast.update(toastId, {
render: 'Profile created at location',
type: 'success',
isLoading: false,
autoClose: 5000,
closeButton: true,
})
}
// Navigate to the profile to show the popup
navigate(`/${result.id}`)
// Clean up and reset state
setFoundLocation(null)
setActive(false)
setHasUpdatedPosition(true)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
if (lc) lc.stop()
// Reset flag after a delay to allow future updates
setTimeout(() => setHasUpdatedPosition(false), 5000)
} catch (error: unknown) {
if (error instanceof Error) {
toast.update(toastId, {
render: error.message,
type: 'error',
isLoading: false,
autoClose: 5000,
closeButton: true,
})
} else if (typeof error === 'string') {
toast.update(toastId, {
render: error,
type: 'error',
isLoading: false,
autoClose: 5000,
closeButton: true,
})
} else {
throw error
}
}
}, [myProfile.myProfile, foundLocation, updateItem, addItem, layers, user, lc, navigate])
return ( return (
<> <>
<div className='tw:card tw:flex-none tw:h-12 tw:w-12 tw:bg-base-100 tw:shadow-xl tw:items-center tw:justify-center tw:hover:bg-slate-300 tw:hover:cursor-pointer tw:transition-all tw:duration-300 tw:ml-2'> <div className='tw:card tw:flex-none tw:h-12 tw:w-12 tw:bg-base-100 tw:shadow-xl tw:items-center tw:justify-center tw:hover:bg-slate-300 tw:hover:cursor-pointer tw:transition-all tw:duration-300 tw:ml-2'>
<div <div
className='tw:card-body tw:card tw:p-2 tw:h-10 tw:w-10 ' className='tw:card-body tw:card tw:p-2 tw:h-10 tw:w-10'
onClick={() => { onClick={handleLocateClick}
if (active) { role='button'
lc.stop() tabIndex={0}
setActive(false) onKeyDown={(e) => {
} else { if (e.key === 'Enter' || e.key === ' ') {
lc.start() e.preventDefault()
setLoading(true) handleLocateClick()
} }
}} }}
aria-label={active ? 'Stop location tracking' : 'Start location tracking'}
> >
{loading ? ( {loading ? (
<span className='tw:loading tw:loading-spinner tw:loading-md tw:mt-1'></span> <span className='tw:loading tw:loading-spinner tw:loading-md tw:mt-1' />
) : ( ) : (
<SVG <SVG
src={TargetSVG} src={TargetSVG}
className='tw:mt-1 tw:p-[1px]' className='tw:mt-1 tw:p-[1px]'
style={{ fill: `${active ? '#fc8702' : 'currentColor'}` }} style={{ fill: active ? '#fc8702' : 'currentColor' }}
/> />
)} )}
</div> </div>
</div> </div>
<DialogModal
title='Location found'
isOpened={showLocationModal}
onClose={() => setShowLocationModal(false)}
showCloseButton={true}
closeOnClickOutside={true}
className='tw:bottom-1/3 tw:mx-4 tw:sm:mx-auto'
>
<div className='tw:text-center'>
<p className='tw:mb-4'>
{myProfile.myProfile
? 'Do you like to place your profile at your current location?'
: 'Do you like to create your profile at your current location?'}
</p>
<div className='tw:flex tw:justify-between'>
<label
className='tw:btn tw:mt-4 tw:btn-primary'
onClick={() => {
void itemUpdatePosition().then(() => setShowLocationModal(false))
}}
>
Yes
</label>
<label
className='tw:btn tw:mt-4'
onClick={() => {
setShowLocationModal(false)
setHasDeclinedModal(true)
}}
>
No
</label>
</div>
</div>
</DialogModal>
</> </>
) )
} }

View File

@ -0,0 +1,137 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<LocateControl /> > Component Rendering > matches snapshot 1`] = `
<div
class="leaflet-container leaflet-touch leaflet-grab leaflet-touch-drag leaflet-touch-zoom"
style="position: relative;"
tabindex="0"
>
<div
class="leaflet-pane leaflet-map-pane"
style="left: 0px; top: 0px;"
>
<div
class="leaflet-pane leaflet-tile-pane"
/>
<div
class="leaflet-pane leaflet-overlay-pane"
/>
<div
class="leaflet-pane leaflet-shadow-pane"
/>
<div
class="leaflet-pane leaflet-marker-pane"
/>
<div
class="leaflet-pane leaflet-tooltip-pane"
/>
<div
class="leaflet-pane leaflet-popup-pane"
/>
</div>
<div
class="leaflet-control-container"
>
<div
class="leaflet-top leaflet-left"
>
<div
class="leaflet-control-zoom leaflet-bar leaflet-control"
>
<a
aria-disabled="false"
aria-label="Zoom in"
class="leaflet-control-zoom-in"
href="#"
role="button"
title="Zoom in"
>
<span
aria-hidden="true"
>
+
</span>
</a>
<a
aria-disabled="false"
aria-label="Zoom out"
class="leaflet-control-zoom-out"
href="#"
role="button"
title="Zoom out"
>
<span
aria-hidden="true"
>
</span>
</a>
</div>
</div>
<div
class="leaflet-top leaflet-right"
/>
<div
class="leaflet-bottom leaflet-left"
/>
<div
class="leaflet-bottom leaflet-right"
>
<div
class="leaflet-control-attribution leaflet-control"
>
<a
href="https://leafletjs.com"
title="A JavaScript library for interactive maps"
>
<svg
aria-hidden="true"
class="leaflet-attribution-flag"
height="8"
viewBox="0 0 12 8"
width="12"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h12v4H0z"
fill="#4C7BE1"
/>
<path
d="M0 4h12v3H0z"
fill="#FFD500"
/>
<path
d="M0 7h12v1H0z"
fill="#E0BC00"
/>
</svg>
Leaflet
</a>
</div>
</div>
</div>
<div
class="tw:card tw:flex-none tw:h-12 tw:w-12 tw:bg-base-100 tw:shadow-xl tw:items-center tw:justify-center tw:hover:bg-slate-300 tw:hover:cursor-pointer tw:transition-all tw:duration-300 tw:ml-2"
>
<div
aria-label="Start location tracking"
class="tw:card-body tw:card tw:p-2 tw:h-10 tw:w-10"
role="button"
tabindex="0"
>
<svg
class="tw:mt-1 tw:p-[1px]"
fill="currentColor"
style="fill: currentColor;"
version="1.1"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M30 14.75h-2.824c-0.608-5.219-4.707-9.318-9.874-9.921l-0.053-0.005v-2.824c0-0.69-0.56-1.25-1.25-1.25s-1.25 0.56-1.25 1.25v0 2.824c-5.219 0.608-9.318 4.707-9.921 9.874l-0.005 0.053h-2.824c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25v0h2.824c0.608 5.219 4.707 9.318 9.874 9.921l0.053 0.005v2.824c0 0.69 0.56 1.25 1.25 1.25s1.25-0.56 1.25-1.25v0-2.824c5.219-0.608 9.318-4.707 9.921-9.874l0.005-0.053h2.824c0.69 0 1.25-0.56 1.25-1.25s-0.56-1.25-1.25-1.25v0zM17.25 24.624v-2.624c0-0.69-0.56-1.25-1.25-1.25s-1.25 0.56-1.25 1.25v0 2.624c-3.821-0.57-6.803-3.553-7.368-7.326l-0.006-0.048h2.624c0.69 0 1.25-0.56 1.25-1.25s-0.56-1.25-1.25-1.25v0h-2.624c0.57-3.821 3.553-6.804 7.326-7.368l0.048-0.006v2.624c0 0.69 0.56 1.25 1.25 1.25s1.25-0.56 1.25-1.25v0-2.624c3.821 0.57 6.803 3.553 7.368 7.326l0.006 0.048h-2.624c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25v0h2.624c-0.571 3.821-3.553 6.803-7.326 7.368l-0.048 0.006z"
/>
</svg>
</div>
</div>
</div>
`;

View File

@ -33,31 +33,31 @@ const DialogModal = ({
if (isOpened) { if (isOpened) {
ref.current?.showModal() ref.current?.showModal()
ref.current?.classList.remove('tw:hidden') ref.current?.classList.remove('tw:hidden')
document.body.classList.add('modal-open') // prevent bg scroll document.body.style.overflow = 'hidden'
} else { } else {
ref.current?.close() ref.current?.close()
ref.current?.classList.add('tw:hidden') ref.current?.classList.add('tw:hidden')
document.body.classList.remove('modal-open') document.body.style.overflow = ''
} }
}, [isOpened]) }, [isOpened])
if (isOpened) { if (isOpened) {
return ( return (
<dialog <dialog
className={`${className ?? ''} card tw:shadow-xl tw:absolute tw:right-0 tw:top-0 tw:bottom-0 tw:left-0 tw:m-auto tw:transition-opacity tw:duration-300 tw:p-4 tw:max-w-xl tw:bg-base-100`} className={`${className ?? ''} tw:card tw:shadow-xl tw:absolute tw:right-0 tw:top-0 tw:bottom-0 tw:left-0 tw:m-auto tw:transition-opacity tw:duration-300 tw:p-4 tw:max-w-xl tw:bg-base-100`}
ref={ref} ref={ref}
onCancel={onClose} onCancel={onClose}
onClick={(e) => onClick={(e) =>
ref.current && !isClickInsideRectangle(e, ref.current) && closeOnClickOutside && onClose() ref.current && !isClickInsideRectangle(e, ref.current) && closeOnClickOutside && onClose()
} }
> >
<div className='card-body tw:p-2'> <div className='tw:card-body tw:p-2'>
<h2 className='tw:text-2xl tw:font-semibold tw:mb-2 tw:text-center'>{title}</h2> <h2 className='tw:text-2xl tw:font-semibold tw:mb-2 tw:text-center'>{title}</h2>
{children} {children}
{showCloseButton && ( {showCloseButton && (
<button <button
className='btn btn-sm btn-circle btn-ghost tw:absolute tw:right-2 tw:top-2' className='tw:btn tw:btn-sm tw:btn-circle tw:btn-ghost tw:absolute tw:right-2 tw:top-2'
onClick={onClose} onClick={onClose}
> >