Add bilingual i18n UI and lighten component shadows

This commit is contained in:
Bernard Ngandu
2025-10-10 10:30:28 +02:00
parent 8f4b954af8
commit 0422becdd0
20 changed files with 622 additions and 141 deletions
+73 -60
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AppHeader } from '@/components/layout/AppHeader'
import { ActivityPanel } from '@/components/panels/ActivityPanel'
@@ -19,33 +20,28 @@ import { useHotspotFeed } from '@/hooks/useHotspotFeed'
import { useLeafletHeatmap } from '@/hooks/useLeafletHeatmap'
import { useUserLocation } from '@/hooks/useUserLocation'
import { distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from '@/lib/utils'
import type { FeedStatus, LatLng } from '@/types/api'
import type { LatLng } from '@/types/api'
const RADIUS_KM = 1
function getStatusLabel(status: FeedStatus): string {
switch (status) {
case 'loading':
return 'Syncing map'
case 'posting':
return 'Sending signal'
case 'refreshing':
return 'Updating heat'
case 'error':
return 'Offline'
default:
return 'Live feed'
}
}
export default function App() {
const [pendingSpot, setPendingSpot] = useState<LatLng | null>(null)
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const [isConfirming, setIsConfirming] = useState(false)
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],
)
const {
status,
errorMessage,
error,
submitPoint,
fetchSnapshot,
selectVisibleDensity,
@@ -88,21 +84,22 @@ export default function App() {
return myLatestPoint
}, [myLatestPoint, userLocation])
const statusLabel = getStatusLabel(status)
const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated) : 'never'
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 locationHint = locationError
? locationError
const translatedLocationError = locationError ? t(locationError) : null
const locationHint = translatedLocationError
? translatedLocationError
: hasLocation
? `Showing reports within ${RADIUS_KM}km of you.`
? t('location.hint.showing', { radius: RADIUS_KM })
: isRequestingLocation
? 'Fetching your location…'
: 'Allow location access to view nearby reports.'
? t('location.hint.requesting')
: t('location.hint.allow')
const { mapContainerRef, focusOn, fitToHeat } = useLeafletHeatmap({
heatCells: visibleDensity,
@@ -159,16 +156,25 @@ export default function App() {
[...visibleDensity]
.sort((a, b) => b.intensity - a.intensity)
.slice(0, 5)
.map((cell, index) => ({
id: `${cell.lat}-${cell.lng}-${index}`,
title: `Hotspot #${index + 1}`,
subtitle: hasLocation
? `${distanceInKm(userLocation!, cell).toFixed(2)}km away · ${formatCoordinate(cell.lat)}°, ${formatCoordinate(cell.lng)}°`
: `${formatCoordinate(cell.lat)}°, ${formatCoordinate(cell.lng)}°`,
intensity: cell.intensity,
onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15),
})),
[visibleDensity, hasLocation, userLocation, focusOn],
.map((cell, index) => {
const coordinates = t('common.coordinates', {
lat: formatCoordinate(cell.lat, locale),
lng: formatCoordinate(cell.lng, locale),
})
return {
id: `${cell.lat}-${cell.lng}-${index}`,
title: t('hotspots.itemTitle', { index: index + 1 }),
subtitle: hasLocation
? t('hotspots.itemSubtitleWithDistance', {
distance: `${distanceFormatter.format(distanceInKm(userLocation!, cell))} km`,
coordinates,
})
: t('hotspots.itemSubtitle', { coordinates }),
intensity: cell.intensity,
onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15),
}
}),
[visibleDensity, hasLocation, userLocation, focusOn, distanceFormatter, t, locale],
)
const recentActivity = useMemo(
@@ -176,21 +182,30 @@ export default function App() {
[...visiblePoints]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 8)
.map((point) => ({
id: point.id,
title: `${formatCoordinate(point.lat)}°, ${formatCoordinate(point.lng)}°`,
subtitle: `User ${point.userKey.slice(0, 4).toUpperCase()}`,
timestampLabel: formatRelativeTime(point.createdAt),
distanceLabel: hasLocation
? `${distanceInKm(userLocation!, point).toFixed(2)}km away`
: formatTimestamp(point.createdAt),
onFocus: () => focusOn({ lat: point.lat, lng: point.lng }, 15),
})),
[visiblePoints, hasLocation, userLocation, focusOn],
.map((point) => {
const coordinates = t('common.coordinates', {
lat: formatCoordinate(point.lat, locale),
lng: formatCoordinate(point.lng, locale),
})
const distanceLabel = userLocation
? t('activityItem.distance', {
distance: `${distanceFormatter.format(distanceInKm(userLocation, point))} km`,
})
: formatTimestamp(point.createdAt, locale)
return {
id: point.id,
title: coordinates,
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),
}
}),
[visiblePoints, userLocation, focusOn, distanceFormatter, t, locale],
)
const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat) : '--'
const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng) : '--'
const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat, locale) : '--'
const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng, locale) : '--'
const isDialogDisabled = !pendingSpot || isConfirming
return (
@@ -216,8 +231,8 @@ export default function App() {
nearbySignals={localTotals.points}
uniqueContributors={localTotals.contributors}
lastUpdatedLabel={lastUpdatedLabel}
mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt) : null}
errorMessage={errorMessage}
mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt, locale) : null}
error={error}
onReport={handleManualReport}
onRetry={handleRefresh}
isPosting={isPosting || isConfirming}
@@ -231,7 +246,7 @@ export default function App() {
locationHint={locationHint}
cells={dangerCells}
/>
<ActivityPanel items={recentActivity} emptyMessage="No recent signals within your area yet." />
<ActivityPanel items={recentActivity} emptyMessage={t('activity.empty')} />
</div>
<div className="order-1 flex w-full flex-1 lg:order-2">
@@ -239,7 +254,7 @@ export default function App() {
containerRef={mapContainerRef}
isPosting={isPosting || isConfirming}
isLoading={isLoading}
confirmationHint={isConfirmOpen ? 'Confirm the new signal in the dialog to send it.' : null}
confirmationHint={isConfirmOpen ? t('map.confirmationHint') : null}
/>
</div>
</section>
@@ -257,26 +272,24 @@ export default function App() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm new signal</AlertDialogTitle>
<AlertDialogDescription>
You&apos;re about to publish a community alert at these coordinates. Double-check the spot before confirming.
</AlertDialogDescription>
<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">Latitude</span>
<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">Longitude</span>
<span className="text-muted-foreground">{t('dialog.confirmSignal.longitude')}</span>
<span className="font-medium text-foreground">{confirmationLng}°</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
Signals are visible to travellers within {RADIUS_KM}km and help the community stay aware of hotspots.
{t('dialog.confirmSignal.reach', { radius: RADIUS_KM })}
</p>
<AlertDialogFooter>
<AlertDialogCancel disabled={isConfirming}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={isConfirming}>{t('dialog.confirmSignal.cancel')}</AlertDialogCancel>
<AlertDialogAction
disabled={isDialogDisabled}
onClick={(event) => {
@@ -284,7 +297,7 @@ export default function App() {
handleConfirmSignal().catch(() => undefined)
}}
>
{isConfirming ? 'Sending' : 'Confirm signal'}
{isConfirming ? t('dialog.confirmSignal.sending') : t('dialog.confirmSignal.confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>