feat: add distance value object and ci workflows

This commit is contained in:
Bernard Ngandu
2025-10-10 16:13:48 +02:00
parent d3338e8901
commit 2ed7a48d36
44 changed files with 4263 additions and 1370 deletions
+283 -285
View File
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Layers, Menu, PanelRightClose, PanelRightOpen } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useMemo, useState } from "react";
import { AppHeader } from '@/components/layout/AppHeader'
import { ActivityPanel } from '@/components/panels/ActivityPanel'
import { HotspotStatsPanel } from '@/components/panels/HotspotStatsPanel'
import { OverviewPanel } from '@/components/panels/OverviewPanel'
import { MapViewport } from '@/components/map/MapViewport'
import { Layers, Menu, PanelRightClose, PanelRightOpen } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AppHeader } from "@/components/layout/AppHeader";
import { MapViewport } from "@/components/map/MapViewport";
import { ActivityPanel } from "@/components/panels/ActivityPanel";
import { HotspotStatsPanel } from "@/components/panels/HotspotStatsPanel";
import { OverviewPanel } from "@/components/panels/OverviewPanel";
import {
AlertDialog,
AlertDialogAction,
@@ -16,61 +17,61 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
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 { Point } from '@/types/api'
import { Toaster } from '@/components/ui/toaster'
import { useToast } from '@/components/ui/use-toast'
import { useAppStore } from '@/store/useAppStore'
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Toaster } from "@/components/ui/toaster";
import { useToast } from "@/components/ui/use-toast";
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 { useAppStore } from "@/store/useAppStore";
import type { Point } from "@/types/api";
const RADIUS_KM = 1
const RADIUS_KM = 1;
export default function App() {
const [pendingSpot, setPendingSpot] = useState<Point | null>(null)
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const [isConfirming, setIsConfirming] = useState(false)
const [tileProvider, setTileProvider] = useState<TileProvider>('openstreetmap')
const [pendingSpot, setPendingSpot] = useState<Point | null>(null);
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const [tileProvider, setTileProvider] = useState<TileProvider>("openstreetmap");
const [isDetailsOpen, setIsDetailsOpen] = useState(() => {
if (typeof window === 'undefined') {
return false
if (typeof window === "undefined") {
return false;
}
return window.innerWidth >= 1024
})
return window.innerWidth >= 1024;
});
const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(() => {
if (typeof window === 'undefined') {
return false
if (typeof window === "undefined") {
return false;
}
return window.innerWidth < 768
})
return window.innerWidth < 768;
});
const { t, i18n } = useTranslation()
const locale = i18n.language === 'fr' ? 'fr-FR' : 'en-US'
const { t, i18n } = useTranslation();
const locale = i18n.language === "fr" ? "fr-FR" : "en-US";
const distanceFormatter = useMemo(
() =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
[locale],
)
[locale]
);
const tileOptions = useMemo(
() => [
{ value: 'openstreetmap' as TileProvider, label: t('map.tiles.openstreetmap') },
{ value: 'mapbox' as TileProvider, label: t('map.tiles.mapbox') },
{ value: "openstreetmap" as TileProvider, label: t("map.tiles.openstreetmap") },
{ value: "mapbox" as TileProvider, label: t("map.tiles.mapbox") },
],
[t],
)
[t]
);
const userLocation = useAppStore((state) => state.userLocation)
const locationError = useAppStore((state) => state.locationError)
const isRequestingLocation = useAppStore((state) => state.isRequestingLocation)
const { refresh: refreshLocation } = useUserLocation()
const userLocation = useAppStore(state => state.userLocation);
const locationError = useAppStore(state => state.locationError);
const isRequestingLocation = useAppStore(state => state.isRequestingLocation);
const { refresh: refreshLocation } = useUserLocation();
const {
status,
@@ -82,123 +83,120 @@ export default function App() {
selectVisibleLatestByUser,
myLatestPoint,
lastUpdated,
} = useHotspotFeed({ userLocation: userLocation ?? null })
} = useHotspotFeed({ userLocation: userLocation ?? null });
const visibleDensity = useMemo(
() => selectVisibleDensity(userLocation ?? null),
[selectVisibleDensity, userLocation],
)
[selectVisibleDensity, userLocation]
);
const visiblePoints = useMemo(
() => selectVisiblePoints(userLocation ?? null),
[selectVisiblePoints, userLocation],
)
const visiblePoints = useMemo(() => selectVisiblePoints(userLocation ?? null), [selectVisiblePoints, userLocation]);
const visibleLatestByUser = useMemo(
() => selectVisibleLatestByUser(userLocation ?? null),
[selectVisibleLatestByUser, userLocation],
)
[selectVisibleLatestByUser, userLocation]
);
const localTotals = useMemo(() => {
const uniqueUsers = new Set<string>()
visibleLatestByUser.forEach((point) => uniqueUsers.add(point.userKey))
return { points: visiblePoints.length, contributors: uniqueUsers.size }
}, [visibleLatestByUser, visiblePoints])
const uniqueUsers = new Set<string>();
visibleLatestByUser.forEach(point => uniqueUsers.add(point.userKey));
return { points: visiblePoints.length, contributors: uniqueUsers.size };
}, [visibleLatestByUser, visiblePoints]);
const myVisibleSignal = useMemo(() => {
if (!myLatestPoint) {
return null
return null;
}
if (userLocation && distanceInKm(userLocation, myLatestPoint.signalLocation) > RADIUS_KM) {
return null
return null;
}
return myLatestPoint
}, [myLatestPoint, userLocation])
return myLatestPoint;
}, [myLatestPoint, userLocation]);
const statusLabel = t(`status.${status}`)
const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated, locale) : t('common.never')
const isLoading = status === 'loading'
const isPosting = status === 'posting'
const isRefreshing = status === 'refreshing'
const hasLocation = Boolean(userLocation)
const showLocationCta = !hasLocation || Boolean(locationError)
const statusLabel = t(`status.${status}`);
const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated, locale) : t("common.never");
const isLoading = status === "loading";
const isPosting = status === "posting";
const isRefreshing = status === "refreshing";
const hasLocation = Boolean(userLocation);
const showLocationCta = !hasLocation || Boolean(locationError);
const translatedLocationError = locationError ? t(locationError) : null
const translatedLocationError = locationError ? t(locationError) : null;
const locationHint = translatedLocationError
? translatedLocationError
: hasLocation
? t('location.hint.showing', { radius: RADIUS_KM })
? t("location.hint.showing", { radius: RADIUS_KM })
: isRequestingLocation
? t('location.hint.requesting')
: t('location.hint.allow')
? t("location.hint.requesting")
: t("location.hint.allow");
const { mapContainerRef, focusOn, fitToHeat } = useLeafletHeatmap({
heatCells: visibleDensity,
userLocation: userLocation ?? null,
onRequestSpot: (position) => {
setPendingSpot(position)
setIsConfirmOpen(true)
onRequestSpot: position => {
setPendingSpot(position);
setIsConfirmOpen(true);
},
tileProvider,
})
});
const { toast } = useToast()
const { toast } = useToast();
useEffect(() => {
if (!error) {
return
return;
}
const description = error.detail ?? t(error.key, error.values ?? {})
const description = error.detail ?? t(error.key, error.values ?? {});
toast({
variant: 'destructive',
title: t('errors.title'),
variant: "destructive",
title: t("errors.title"),
description,
duration: 6000,
})
}, [error, t, toast])
});
}, [error, t, toast]);
const handleConfirmSignal = useCallback(async () => {
if (!pendingSpot) {
return
return;
}
setIsConfirming(true)
const result = await submitPoint(pendingSpot)
setIsConfirming(false)
setIsConfirming(true);
const result = await submitPoint(pendingSpot);
setIsConfirming(false);
if (result.success) {
setIsConfirmOpen(false)
setPendingSpot(null)
setIsConfirmOpen(false);
setPendingSpot(null);
}
}, [pendingSpot, submitPoint])
}, [pendingSpot, submitPoint]);
const handleRefresh = useCallback(() => {
fetchSnapshot().catch(() => undefined)
}, [fetchSnapshot])
fetchSnapshot().catch(() => undefined);
}, [fetchSnapshot]);
const handleFocusHeat = useCallback(() => {
fitToHeat()
}, [fitToHeat])
fitToHeat();
}, [fitToHeat]);
const handleLocateUser = useCallback(() => {
if (userLocation) {
focusOn(userLocation, 14)
focusOn(userLocation, 14);
} else {
refreshLocation()
refreshLocation();
}
}, [focusOn, refreshLocation, userLocation])
}, [focusOn, refreshLocation, userLocation]);
const handleFocusMySignal = useCallback(() => {
if (myVisibleSignal) {
focusOn({ lat: myVisibleSignal.signalLocation.lat, lng: myVisibleSignal.signalLocation.lng }, 15)
focusOn({ lat: myVisibleSignal.signalLocation.lat, lng: myVisibleSignal.signalLocation.lng }, 15);
}
}, [focusOn, myVisibleSignal])
}, [focusOn, myVisibleSignal]);
const handleManualReport = useCallback(() => {
if (!userLocation) {
return
return;
}
setPendingSpot(userLocation)
setIsConfirmOpen(true)
}, [userLocation])
setPendingSpot(userLocation);
setIsConfirmOpen(true);
}, [userLocation]);
const dangerCells = useMemo(
() =>
@@ -206,239 +204,239 @@ export default function App() {
.sort((a, b) => b.intensity - a.intensity)
.slice(0, 5)
.map((cell, index) => {
const coordinates = t('common.coordinates', {
const coordinates = t("common.coordinates", {
lat: formatCoordinate(cell.lat, locale),
lng: formatCoordinate(cell.lng, locale),
})
});
const distanceLabel = userLocation
? `${distanceFormatter.format(distanceInKm(userLocation, cell))} km`
: null
: null;
return {
id: `${cell.lat}-${cell.lng}-${index}`,
title: t('hotspots.itemTitle', { index: index + 1 }),
title: t("hotspots.itemTitle", { index: index + 1 }),
subtitle:
distanceLabel !== null
? t('hotspots.itemSubtitleWithDistance', { distance: distanceLabel, coordinates })
: t('hotspots.itemSubtitle', { coordinates }),
? t("hotspots.itemSubtitleWithDistance", { distance: distanceLabel, coordinates })
: t("hotspots.itemSubtitle", { coordinates }),
intensity: cell.intensity,
onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15),
}
};
}),
[visibleDensity, userLocation, focusOn, distanceFormatter, t, locale],
)
[visibleDensity, userLocation, focusOn, distanceFormatter, t, locale]
);
const recentActivity = useMemo(
() =>
[...visiblePoints]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 8)
.map((point) => {
const coordinates = t('common.coordinates', {
.map(point => {
const coordinates = t("common.coordinates", {
lat: formatCoordinate(point.signalLocation.lat, locale),
lng: formatCoordinate(point.signalLocation.lng, locale),
})
});
const distanceLabel = userLocation
? t('activityItem.distance', {
? t("activityItem.distance", {
distance: `${distanceFormatter.format(distanceInKm(userLocation, point.signalLocation))} km`,
})
: formatTimestamp(point.createdAt, locale)
: formatTimestamp(point.createdAt, locale);
return {
id: point.id,
title: coordinates,
subtitle: t('activityItem.user', { id: point.userKey.slice(0, 4).toUpperCase() }),
subtitle: t("activityItem.user", { id: point.userKey.slice(0, 4).toUpperCase() }),
timestampLabel: formatRelativeTime(point.createdAt, locale),
distanceLabel,
onFocus: () => focusOn({ lat: point.signalLocation.lat, lng: point.signalLocation.lng }, 15),
}
};
}),
[visiblePoints, userLocation, focusOn, distanceFormatter, t, locale],
)
[visiblePoints, userLocation, focusOn, distanceFormatter, t, locale]
);
const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat, locale) : '--'
const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng, locale) : '--'
const isDialogDisabled = !pendingSpot || isConfirming
const detailsToggleLabel = isDetailsOpen ? t('details.close') : t('details.open')
const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat, locale) : "--";
const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng, locale) : "--";
const isDialogDisabled = !pendingSpot || isConfirming;
const detailsToggleLabel = isDetailsOpen ? t("details.close") : t("details.open");
const detailsPanelClassName = cn(
'pointer-events-auto fixed inset-x-0 bottom-0 z-40 flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-3xl border border-border/70 bg-background/95 shadow-2xl backdrop-blur transition-transform duration-300 sm:left-auto sm:right-6 sm:top-24 sm:bottom-6 sm:max-h-[calc(100vh-8rem)] sm:w-[min(380px,calc(100vw-4rem))] sm:rounded-3xl',
isDetailsOpen
? 'translate-y-0 sm:translate-x-0'
: 'translate-y-[calc(100%+1rem)] sm:translate-x-[calc(100%+2rem)]',
)
"pointer-events-auto fixed inset-x-0 bottom-0 z-40 flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-3xl border border-border/70 bg-background/95 shadow-2xl backdrop-blur transition-transform duration-300 sm:left-auto sm:right-6 sm:top-24 sm:bottom-6 sm:max-h-[calc(100vh-8rem)] sm:w-[min(380px,calc(100vw-4rem))] sm:rounded-3xl",
isDetailsOpen ? "translate-y-0 sm:translate-x-0" : "translate-y-[calc(100%+1rem)] sm:translate-x-[calc(100%+2rem)]"
);
return (
<>
<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}
className="min-h-screen"
/>
isPosting={isPosting || isConfirming}
isLoading={isLoading}
confirmationHint={isConfirmOpen ? t("map.confirmationHint") : null}
className="min-h-screen"
/>
<div className="pointer-events-none absolute inset-0 flex flex-col">
<div className="flex items-start justify-between gap-3 p-4 sm:p-6">
<div className="pointer-events-auto">
<AppHeader
status={status}
statusLabel={statusLabel}
lastUpdatedLabel={lastUpdatedLabel}
onRefresh={handleRefresh}
onFocusHeat={handleFocusHeat}
onLocateUser={handleLocateUser}
onFocusMySignal={handleFocusMySignal}
disableRefresh={isLoading || isRefreshing || isPosting}
disableHeat={visibleDensity.length === 0}
disableLocate={!hasLocation}
disableMySignal={!myVisibleSignal}
collapsed={isHeaderCollapsed}
onToggleCollapse={() => setIsHeaderCollapsed((prev) => !prev)}
/>
<div className="pointer-events-none absolute inset-0 flex flex-col">
<div className="flex items-start justify-between gap-3 p-4 sm:p-6">
<div className="pointer-events-auto">
<AppHeader
status={status}
statusLabel={statusLabel}
lastUpdatedLabel={lastUpdatedLabel}
onRefresh={handleRefresh}
onFocusHeat={handleFocusHeat}
onLocateUser={handleLocateUser}
onFocusMySignal={handleFocusMySignal}
disableRefresh={isLoading || isRefreshing || isPosting}
disableHeat={visibleDensity.length === 0}
disableLocate={!hasLocation}
disableMySignal={!myVisibleSignal}
collapsed={isHeaderCollapsed}
onToggleCollapse={() => setIsHeaderCollapsed(prev => !prev)}
/>
</div>
<div className="pointer-events-auto hidden sm:flex">
<Button
variant="secondary"
size="icon"
onClick={() => setIsDetailsOpen(prev => !prev)}
aria-label={detailsToggleLabel}
aria-expanded={isDetailsOpen}
className="h-11 w-11 rounded-full border border-border/60 bg-background/80 shadow-lg backdrop-blur hover:bg-background"
>
{isDetailsOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="pointer-events-auto hidden sm:flex">
</div>
{!isDetailsOpen && (
<div className="pointer-events-auto fixed bottom-4 right-4 z-30 sm:hidden">
<Button
variant="secondary"
size="icon"
onClick={() => setIsDetailsOpen((prev) => !prev)}
size="sm"
onClick={() => setIsDetailsOpen(true)}
aria-label={detailsToggleLabel}
aria-expanded={isDetailsOpen}
className="h-11 w-11 rounded-full border border-border/60 bg-background/80 shadow-lg backdrop-blur hover:bg-background"
className="flex items-center gap-2 rounded-full border border-border/60 bg-background/85 px-4 py-2 text-xs font-semibold uppercase tracking-wide shadow-lg backdrop-blur"
>
{isDetailsOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
<Menu className="h-4 w-4" />
<span>{t("details.open")}</span>
</Button>
</div>
</div>
</div>
)}
{!isDetailsOpen && (
<div className="pointer-events-auto fixed bottom-4 right-4 z-30 sm:hidden">
<Button
variant="secondary"
size="sm"
onClick={() => setIsDetailsOpen(true)}
aria-label={detailsToggleLabel}
className="flex items-center gap-2 rounded-full border border-border/60 bg-background/85 px-4 py-2 text-xs font-semibold uppercase tracking-wide shadow-lg backdrop-blur"
>
<Menu className="h-4 w-4" />
<span>{t('details.open')}</span>
</Button>
</div>
)}
<aside
className={detailsPanelClassName}
role="complementary"
aria-label={t('details.title')}
aria-hidden={!isDetailsOpen}
>
<header className="flex items-center justify-between gap-2 border-b border-border/60 bg-background/80 px-4 py-3 backdrop-blur">
<div className="flex flex-col">
<span className="text-sm font-semibold">{t('details.title')}</span>
<span className="text-xs text-muted-foreground">{t('details.description')}</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setIsDetailsOpen(false)}
aria-label={t('details.close')}
className="h-9 w-9 rounded-full border border-border/60 bg-muted/60 backdrop-blur hover:bg-muted"
>
<PanelRightClose className="h-4 w-4" />
</Button>
</header>
<ScrollArea className="flex-1">
<div className="flex flex-col gap-4 p-4 pb-6">
<div className="rounded-2xl border border-border/60 bg-muted/40 p-4 shadow-sm">
<div className="flex items-start gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl border border-border/60 bg-background/80 text-primary">
<Layers className="h-5 w-5" />
</span>
<div className="flex flex-1 flex-col">
<span className="text-sm font-semibold">{t('map.tiles.title')}</span>
<span className="text-xs text-muted-foreground">{t('map.tiles.subtitle')}</span>
<aside
className={detailsPanelClassName}
role="complementary"
aria-label={t("details.title")}
aria-hidden={!isDetailsOpen}
>
<header className="flex items-center justify-between gap-2 border-b border-border/60 bg-background/80 px-4 py-3 backdrop-blur">
<div className="flex flex-col">
<span className="text-sm font-semibold">{t("details.title")}</span>
<span className="text-xs text-muted-foreground">{t("details.description")}</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setIsDetailsOpen(false)}
aria-label={t("details.close")}
className="h-9 w-9 rounded-full border border-border/60 bg-muted/60 backdrop-blur hover:bg-muted"
>
<PanelRightClose className="h-4 w-4" />
</Button>
</header>
<ScrollArea className="flex-1">
<div className="flex flex-col gap-4 p-4 pb-6">
<div className="rounded-2xl border border-border/60 bg-muted/40 p-4 shadow-sm">
<div className="flex items-start gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl border border-border/60 bg-background/80 text-primary">
<Layers className="h-5 w-5" />
</span>
<div className="flex flex-1 flex-col">
<span className="text-sm font-semibold">{t("map.tiles.title")}</span>
<span className="text-xs text-muted-foreground">{t("map.tiles.subtitle")}</span>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{tileOptions.map(option => (
<Button
key={option.value}
type="button"
variant={tileProvider === option.value ? "default" : "outline"}
size="sm"
onClick={() => setTileProvider(option.value)}
className={cn(
"rounded-full border border-border/60 px-4 py-1 text-xs font-medium transition-colors",
tileProvider === option.value
? "shadow-sm"
: "bg-background/80 text-muted-foreground hover:bg-background"
)}
>
{option.label}
</Button>
))}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{tileOptions.map((option) => (
<Button
key={option.value}
type="button"
variant={tileProvider === option.value ? 'default' : 'outline'}
size="sm"
onClick={() => setTileProvider(option.value)}
className={cn(
'rounded-full border border-border/60 px-4 py-1 text-xs font-medium transition-colors',
tileProvider === option.value
? 'shadow-sm'
: 'bg-background/80 text-muted-foreground hover:bg-background',
)}
>
{option.label}
</Button>
))}
<OverviewPanel
nearbySignals={localTotals.points}
uniqueContributors={localTotals.contributors}
lastUpdatedLabel={lastUpdatedLabel}
mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt, locale) : null}
error={error}
onReport={handleManualReport}
onRetry={handleRefresh}
isPosting={isPosting || isConfirming}
locationHint={locationHint}
showLocationCta={showLocationCta}
disableReport={!hasLocation}
/>
<HotspotStatsPanel
hasLocation={hasLocation}
radiusKm={RADIUS_KM}
locationHint={locationHint}
cells={dangerCells}
/>
<ActivityPanel items={recentActivity} emptyMessage={t("activity.empty")} />
</div>
</ScrollArea>
</aside>
<AlertDialog
open={isConfirmOpen}
onOpenChange={nextOpen => {
setIsConfirmOpen(nextOpen);
if (!nextOpen) {
setPendingSpot(null);
setIsConfirming(false);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("dialog.confirmSignal.title")}</AlertDialogTitle>
<AlertDialogDescription>{t("dialog.confirmSignal.description")}</AlertDialogDescription>
</AlertDialogHeader>
<div className="rounded-2xl border border-border/60 bg-muted/40 p-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("dialog.confirmSignal.latitude")}</span>
<span className="font-medium text-foreground">{confirmationLat}°</span>
</div>
<div className="mt-3 flex items-center justify-between">
<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>
<OverviewPanel
nearbySignals={localTotals.points}
uniqueContributors={localTotals.contributors}
lastUpdatedLabel={lastUpdatedLabel}
mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt, locale) : null}
error={error}
onReport={handleManualReport}
onRetry={handleRefresh}
isPosting={isPosting || isConfirming}
locationHint={locationHint}
showLocationCta={showLocationCta}
disableReport={!hasLocation}
/>
<HotspotStatsPanel
hasLocation={hasLocation}
radiusKm={RADIUS_KM}
locationHint={locationHint}
cells={dangerCells}
/>
<ActivityPanel items={recentActivity} emptyMessage={t('activity.empty')} />
</div>
</ScrollArea>
</aside>
<AlertDialog
open={isConfirmOpen}
onOpenChange={(nextOpen) => {
setIsConfirmOpen(nextOpen)
if (!nextOpen) {
setPendingSpot(null)
setIsConfirming(false)
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('dialog.confirmSignal.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('dialog.confirmSignal.description')}</AlertDialogDescription>
</AlertDialogHeader>
<div className="rounded-2xl border border-border/60 bg-muted/40 p-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('dialog.confirmSignal.latitude')}</span>
<span className="font-medium text-foreground">{confirmationLat}°</span>
</div>
<div className="mt-3 flex items-center justify-between">
<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>
<AlertDialogFooter>
<AlertDialogCancel disabled={isConfirming}>{t('dialog.confirmSignal.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSignal} disabled={isDialogDisabled}>
{isConfirming ? t('dialog.confirmSignal.sending') : t('dialog.confirmSignal.confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialogFooter>
<AlertDialogCancel disabled={isConfirming}>{t("dialog.confirmSignal.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSignal} disabled={isDialogDisabled}>
{isConfirming ? t("dialog.confirmSignal.sending") : t("dialog.confirmSignal.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Toaster />
</>
)
);
}