Refactor point value object and add observability
This commit is contained in:
+87
-28
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Layers, Menu, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Layers, Loader2, Menu, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { AppHeader } from '@/components/layout/AppHeader'
|
||||
@@ -23,12 +23,39 @@ import { useHotspotFeed } from '@/hooks/useHotspotFeed'
|
||||
import { useLeafletHeatmap, type TileProvider } from '@/hooks/useLeafletHeatmap'
|
||||
import { useUserLocation } from '@/hooks/useUserLocation'
|
||||
import { cn, distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from '@/lib/utils'
|
||||
import type { LatLng } from '@/types/api'
|
||||
import type { Point } from '@/types/api'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
|
||||
const RADIUS_KM = 1
|
||||
|
||||
interface LocationGateProps {
|
||||
title: string
|
||||
message: string
|
||||
actionLabel: string
|
||||
onRetry: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
function LocationGate({ title, message, actionLabel, onRetry, isLoading }: LocationGateProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-6 text-center">
|
||||
<div className="max-w-md space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
<Button onClick={onRetry} disabled={isLoading} className="inline-flex items-center gap-2">
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
<span>{actionLabel}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [pendingSpot, setPendingSpot] = useState<LatLng | null>(null)
|
||||
const [pendingSpot, setPendingSpot] = useState<Point | null>(null)
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
|
||||
const [isConfirming, setIsConfirming] = useState(false)
|
||||
const [tileProvider, setTileProvider] = useState<TileProvider>('openstreetmap')
|
||||
@@ -44,6 +71,7 @@ export default function App() {
|
||||
}
|
||||
return window.innerWidth < 768
|
||||
})
|
||||
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = i18n.language === 'fr' ? 'fr-FR' : 'en-US'
|
||||
const distanceFormatter = useMemo(
|
||||
@@ -63,6 +91,13 @@ export default function App() {
|
||||
[t],
|
||||
)
|
||||
|
||||
const {
|
||||
location: userLocation,
|
||||
error: locationError,
|
||||
isRequesting: isRequestingLocation,
|
||||
refresh: refreshLocation,
|
||||
} = useUserLocation()
|
||||
|
||||
const {
|
||||
status,
|
||||
error,
|
||||
@@ -73,9 +108,7 @@ export default function App() {
|
||||
selectVisibleLatestByUser,
|
||||
myLatestPoint,
|
||||
lastUpdated,
|
||||
} = useHotspotFeed()
|
||||
|
||||
const { location: userLocation, error: locationError, isRequesting: isRequestingLocation } = useUserLocation()
|
||||
} = useHotspotFeed({ userLocation: userLocation ?? null })
|
||||
|
||||
const visibleDensity = useMemo(
|
||||
() => selectVisibleDensity(userLocation ?? null),
|
||||
@@ -102,7 +135,7 @@ export default function App() {
|
||||
if (!myLatestPoint) {
|
||||
return null
|
||||
}
|
||||
if (userLocation && distanceInKm(userLocation, myLatestPoint) > RADIUS_KM) {
|
||||
if (userLocation && distanceInKm(userLocation, myLatestPoint.signalLocation) > RADIUS_KM) {
|
||||
return null
|
||||
}
|
||||
return myLatestPoint
|
||||
@@ -135,12 +168,27 @@ export default function App() {
|
||||
tileProvider,
|
||||
})
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
return
|
||||
}
|
||||
const description = error.detail ?? t(error.key, error.values ?? {})
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('errors.title'),
|
||||
description,
|
||||
duration: 6000,
|
||||
})
|
||||
}, [error, t, toast])
|
||||
|
||||
const handleConfirmSignal = useCallback(async () => {
|
||||
if (!pendingSpot) {
|
||||
return
|
||||
}
|
||||
setIsConfirming(true)
|
||||
const result = await submitPoint(pendingSpot.lat, pendingSpot.lng)
|
||||
const result = await submitPoint(pendingSpot)
|
||||
setIsConfirming(false)
|
||||
if (result.success) {
|
||||
setIsConfirmOpen(false)
|
||||
@@ -164,7 +212,7 @@ export default function App() {
|
||||
|
||||
const handleFocusMySignal = useCallback(() => {
|
||||
if (myVisibleSignal) {
|
||||
focusOn({ lat: myVisibleSignal.lat, lng: myVisibleSignal.lng }, 15)
|
||||
focusOn({ lat: myVisibleSignal.signalLocation.lat, lng: myVisibleSignal.signalLocation.lng }, 15)
|
||||
}
|
||||
}, [focusOn, myVisibleSignal])
|
||||
|
||||
@@ -209,12 +257,12 @@ export default function App() {
|
||||
.slice(0, 8)
|
||||
.map((point) => {
|
||||
const coordinates = t('common.coordinates', {
|
||||
lat: formatCoordinate(point.lat, locale),
|
||||
lng: formatCoordinate(point.lng, locale),
|
||||
lat: formatCoordinate(point.signalLocation.lat, locale),
|
||||
lng: formatCoordinate(point.signalLocation.lng, locale),
|
||||
})
|
||||
const distanceLabel = userLocation
|
||||
? t('activityItem.distance', {
|
||||
distance: `${distanceFormatter.format(distanceInKm(userLocation, point))} km`,
|
||||
distance: `${distanceFormatter.format(distanceInKm(userLocation, point.signalLocation))} km`,
|
||||
})
|
||||
: formatTimestamp(point.createdAt, locale)
|
||||
return {
|
||||
@@ -223,7 +271,7 @@ export default function App() {
|
||||
subtitle: t('activityItem.user', { id: point.userKey.slice(0, 4).toUpperCase() }),
|
||||
timestampLabel: formatRelativeTime(point.createdAt, locale),
|
||||
distanceLabel,
|
||||
onFocus: () => focusOn({ lat: point.lat, lng: point.lng }, 15),
|
||||
onFocus: () => focusOn({ lat: point.signalLocation.lat, lng: point.signalLocation.lng }, 15),
|
||||
}
|
||||
}),
|
||||
[visiblePoints, userLocation, focusOn, distanceFormatter, t, locale],
|
||||
@@ -240,10 +288,27 @@ export default function App() {
|
||||
: 'translate-y-[calc(100%+1rem)] sm:translate-x-[calc(100%+2rem)]',
|
||||
)
|
||||
|
||||
if (!userLocation) {
|
||||
const gateTitle = t('location.gate.title')
|
||||
const gateMessage = locationError ? t(locationError) : t('location.gate.description')
|
||||
const gateAction = isRequestingLocation ? t('location.gate.loading') : t('location.gate.action')
|
||||
|
||||
return (
|
||||
<LocationGate
|
||||
title={gateTitle}
|
||||
message={gateMessage}
|
||||
actionLabel={gateAction}
|
||||
onRetry={refreshLocation}
|
||||
isLoading={isRequestingLocation}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full overflow-hidden bg-background text-foreground">
|
||||
<MapViewport
|
||||
containerRef={mapContainerRef}
|
||||
<>
|
||||
<div className="relative min-h-screen w-full overflow-hidden bg-background text-foreground">
|
||||
<MapViewport
|
||||
containerRef={mapContainerRef}
|
||||
isPosting={isPosting || isConfirming}
|
||||
isLoading={isLoading}
|
||||
confirmationHint={isConfirmOpen ? t('map.confirmationHint') : null}
|
||||
@@ -401,24 +466,18 @@ export default function App() {
|
||||
<span className="text-muted-foreground">{t('dialog.confirmSignal.longitude')}</span>
|
||||
<span className="font-medium text-foreground">{confirmationLng}°</span>
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-muted-foreground">{t('dialog.confirmSignal.reach', { radius: RADIUS_KM })}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('dialog.confirmSignal.reach', { radius: RADIUS_KM })}
|
||||
</p>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isConfirming}>{t('dialog.confirmSignal.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isDialogDisabled}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
handleConfirmSignal().catch(() => undefined)
|
||||
}}
|
||||
>
|
||||
<AlertDialogAction onClick={handleConfirmSignal} disabled={isDialogDisabled}>
|
||||
{isConfirming ? t('dialog.confirmSignal.sending') : t('dialog.confirmSignal.confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
<Toaster />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user