diff --git a/client/package-lock.json b/client/package-lock.json index dd3ec7a..f1a105c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -15,11 +15,13 @@ "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "i18next": "^25.5.3", "leaflet": "^1.9.4", "leaflet.heat": "^0.2.0", "lucide-react": "^0.545.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-i18next": "^16.0.0", "tailwind-merge": "^3.3.1", "tslib": "^2.8.1" }, @@ -291,6 +293,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -2922,6 +2933,46 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.5.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.5.3.tgz", + "integrity": "sha512-joFqorDeQ6YpIXni944upwnuHBf5IoPMuqAchGVeQLdWC2JOjxgM9V8UGLhNIIH/Q8QleRxIi0BSRQehSrDLcg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4007,6 +4058,32 @@ "react": "^19.2.0" } }, + "node_modules/react-i18next": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.0.0.tgz", + "integrity": "sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 25.5.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4629,7 +4706,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4869,6 +4946,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/client/package.json b/client/package.json index f72bac6..2a2bb8c 100644 --- a/client/package.json +++ b/client/package.json @@ -17,11 +17,13 @@ "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "i18next": "^25.5.3", "leaflet": "^1.9.4", "leaflet.heat": "^0.2.0", "lucide-react": "^0.545.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-i18next": "^16.0.0", "tailwind-merge": "^3.3.1", "tslib": "^2.8.1" }, diff --git a/client/src/App.tsx b/client/src/App.tsx index 1e74fe0..d47e072 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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(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} /> - +
@@ -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} />
@@ -257,26 +272,24 @@ export default function App() { > - Confirm new signal - - You're about to publish a community alert at these coordinates. Double-check the spot before confirming. - + {t('dialog.confirmSignal.title')} + {t('dialog.confirmSignal.description')}
- Latitude + {t('dialog.confirmSignal.latitude')} {confirmationLat}°
- Longitude + {t('dialog.confirmSignal.longitude')} {confirmationLng}°

- Signals are visible to travellers within {RADIUS_KM}km and help the community stay aware of hotspots. + {t('dialog.confirmSignal.reach', { radius: RADIUS_KM })}

- Cancel + {t('dialog.confirmSignal.cancel')} { @@ -284,7 +297,7 @@ export default function App() { handleConfirmSignal().catch(() => undefined) }} > - {isConfirming ? 'Sending…' : 'Confirm signal'} + {isConfirming ? t('dialog.confirmSignal.sending') : t('dialog.confirmSignal.confirm')}
diff --git a/client/src/components/layout/AppHeader.tsx b/client/src/components/layout/AppHeader.tsx index b72277d..7bee720 100644 --- a/client/src/components/layout/AppHeader.tsx +++ b/client/src/components/layout/AppHeader.tsx @@ -1,7 +1,9 @@ import { Flame, Focus, LocateFixed, MapPin, RefreshCw } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { LanguageToggle } from '@/components/layout/LanguageToggle' import { ThemeToggle } from '@/components/layout/ThemeToggle' import type { FeedStatus } from '@/types/api' @@ -32,6 +34,7 @@ export function AppHeader({ disableLocate, disableMySignal, }: AppHeaderProps) { + const { t } = useTranslation() const isError = status === 'error' return ( @@ -42,8 +45,8 @@ export function AppHeader({
- SignalMap - Crowd signals around your route + {t('app.name')} + {t('app.tagline')}
@@ -62,20 +65,47 @@ export function AppHeader({ {statusLabel} - {lastUpdatedLabel} + + {t('header.badge.updated', { time: lastUpdatedLabel })} + - - - - +
diff --git a/client/src/components/layout/LanguageToggle.tsx b/client/src/components/layout/LanguageToggle.tsx new file mode 100644 index 0000000..63480c4 --- /dev/null +++ b/client/src/components/layout/LanguageToggle.tsx @@ -0,0 +1,28 @@ +import { Globe } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +import { Button } from '@/components/ui/button' + +export function LanguageToggle() { + const { i18n, t } = useTranslation() + const current = i18n.language === 'fr' ? 'fr' : 'en' + const next = current === 'en' ? 'fr' : 'en' + const nextLabel = t(next === 'en' ? 'language.english' : 'language.french') + + return ( + + ) +} + diff --git a/client/src/components/layout/ThemeToggle.tsx b/client/src/components/layout/ThemeToggle.tsx index ed19503..373ae3e 100644 --- a/client/src/components/layout/ThemeToggle.tsx +++ b/client/src/components/layout/ThemeToggle.tsx @@ -1,16 +1,19 @@ import { Moon, Sun } from 'lucide-react' +import { useTranslation } from 'react-i18next' + import { Button } from '@/components/ui/button' import { useTheme } from '@/hooks/useTheme' export function ThemeToggle() { const { toggleTheme, isDark } = useTheme() + const { t } = useTranslation() return ( diff --git a/client/src/components/panels/HotspotStatsPanel.tsx b/client/src/components/panels/HotspotStatsPanel.tsx index 7c5a669..e3ff0d9 100644 --- a/client/src/components/panels/HotspotStatsPanel.tsx +++ b/client/src/components/panels/HotspotStatsPanel.tsx @@ -1,4 +1,5 @@ import { Flame, MapPin } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -21,19 +22,21 @@ interface HotspotStatsPanelProps { } export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells }: HotspotStatsPanelProps) { + const { t } = useTranslation() + return ( - Danger zone intel + {t('hotspots.title')} - Highest intensity heat within {radiusKm}km. + {t('hotspots.description', { radius: radiusKm })} {!hasLocation &&

{locationHint}

} {hasLocation && cells.length === 0 && ( -

No active hotspots nearby. Tap the map to log a new signal.

+

{t('hotspots.empty')}

)} {cells.length > 0 && ( @@ -55,7 +58,7 @@ export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells } className="mt-2 w-full justify-center gap-2 text-xs" onClick={cell.onFocus} > - Focus + {t('hotspots.focus')} ))} diff --git a/client/src/components/panels/OverviewPanel.tsx b/client/src/components/panels/OverviewPanel.tsx index 514e2e9..c343198 100644 --- a/client/src/components/panels/OverviewPanel.tsx +++ b/client/src/components/panels/OverviewPanel.tsx @@ -1,15 +1,17 @@ import { AlertCircle, Radio } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import type { FeedError } from '@/hooks/useHotspotFeed' interface OverviewPanelProps { nearbySignals: number uniqueContributors: number lastUpdatedLabel: string mySignalLabel: string | null - errorMessage: string | null + error: FeedError | null onReport: () => void onRetry: () => void isPosting: boolean @@ -23,7 +25,7 @@ export function OverviewPanel({ uniqueContributors, lastUpdatedLabel, mySignalLabel, - errorMessage, + error, onReport, onRetry, isPosting, @@ -31,29 +33,32 @@ export function OverviewPanel({ showLocationCta, disableReport, }: OverviewPanelProps) { + const { t } = useTranslation() + const errorMessage = error ? t(error.key, error.values) : null + return ( - Nearby coverage + {t('overview.title')} - Signals refresh automatically every few seconds. + {t('overview.description')}
- Signals + {t('overview.stats.signals')}

{nearbySignals}

- Contributors + {t('overview.stats.contributors')}

{uniqueContributors}

{mySignalLabel && ( - Your last signal {mySignalLabel} + {t('overview.badge', { time: mySignalLabel })} )} {errorMessage ? ( @@ -62,7 +67,7 @@ export function OverviewPanel({

{errorMessage}

@@ -70,12 +75,16 @@ export function OverviewPanel({

{locationHint}

)} {showLocationCta && !errorMessage && ( -

Allow location permissions to personalise the feed.

+

{t('overview.locationPermission')}

)} -

Last synced {lastUpdatedLabel}

+

{t('overview.lastSynced', { time: lastUpdatedLabel })}

) diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx index 3310a91..26a38ba 100644 --- a/client/src/components/ui/alert-dialog.tsx +++ b/client/src/components/ui/alert-dialog.tsx @@ -33,7 +33,7 @@ const AlertDialogContent = React.forwardRef< , SheetContentProps ->(({ side = 'bottom', className, children, ...props }, ref) => ( - - - - {children} - - Close - Close - - - -)) +>(({ side = 'bottom', className, children, ...props }, ref) => { + const { t } = useTranslation() + + return ( + + + + {children} + + {t('common.actions.close')} + {t('common.aria.sheet.close')} + + + + ) +}) SheetContent.displayName = SheetPrimitive.Content.displayName const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( diff --git a/client/src/hooks/useHotspotFeed.ts b/client/src/hooks/useHotspotFeed.ts index 5d1d32c..c817dda 100644 --- a/client/src/hooks/useHotspotFeed.ts +++ b/client/src/hooks/useHotspotFeed.ts @@ -16,9 +16,14 @@ interface SubmitResult { success: boolean } +export interface FeedError { + key: string + values?: Record +} + export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspotFeedOptions = {}) { const [status, setStatus] = useState('loading') - const [errorMessage, setErrorMessage] = useState(null) + const [error, setError] = useState(null) const [rawPoints, setRawPoints] = useState([]) const [rawDensity, setRawDensity] = useState([]) const [rawLatestByUser, setRawLatestByUser] = useState([]) @@ -50,7 +55,7 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo try { const response = await fetch(`${API_BASE}?limit=${SNAPSHOT_LIMIT}`, { cache: 'no-store' }) if (!response.ok) { - throw new Error('Unable to reach the hotspot feed.') + throw new Error('feed-unavailable') } const data: ApiSnapshot = await response.json() @@ -59,14 +64,15 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo setRawLatestByUser(data.latestByUser ?? []) setClientKey(data.clientKey ?? null) setLastUpdated(data.updatedAt ?? new Date().toISOString()) - setErrorMessage(null) + setError(null) initialLoadRef.current = false const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle' setStatusSafe(nextStatus) } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error while loading hotspots.' - setErrorMessage(message) + const message = error instanceof Error ? error.message : null + const key = message === 'feed-unavailable' ? 'errors.feedUnavailable' : 'errors.feedUnknown' + setError({ key }) if (initialLoadRef.current) { setStatusSafe('error') } else if (previousStatus !== 'posting') { @@ -104,16 +110,27 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo if (!response.ok) { const payload = await response.json().catch(() => null) - const message = payload?.message ?? 'Unable to store your signal.' - throw new Error(message) + const message = payload?.message as string | undefined + if (message) { + setError({ key: 'errors.submitWithReason', values: { message } }) + } else { + setError({ key: 'errors.submitUnavailable' }) + } + setStatusSafe('error') + return { success: false } } await fetchSnapshot({ silent: true }) + setError(null) setStatusSafe('idle') return { success: true } } catch (error) { - const message = error instanceof Error ? error.message : 'Something went wrong while saving your signal.' - setErrorMessage(message) + const message = error instanceof Error ? error.message : null + if (message) { + setError({ key: 'errors.submitWithReason', values: { message } }) + } else { + setError({ key: 'errors.submitUnknown' }) + } setStatusSafe('error') return { success: false } } @@ -157,7 +174,7 @@ export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspo return { status, - errorMessage, + error, submitPoint, fetchSnapshot, rawDensity, diff --git a/client/src/hooks/useUserLocation.ts b/client/src/hooks/useUserLocation.ts index 5fdb0ba..376d00e 100644 --- a/client/src/hooks/useUserLocation.ts +++ b/client/src/hooks/useUserLocation.ts @@ -5,13 +5,13 @@ import type { LatLng } from '@/types/api' function geolocationErrorMessage(error: GeolocationPositionError): string { switch (error.code) { case error.PERMISSION_DENIED: - return 'Location access denied. Enable it to view nearby pings.' + return 'location.error.permissionDenied' case error.POSITION_UNAVAILABLE: - return 'Unable to determine your position. Try again.' + return 'location.error.unavailable' case error.TIMEOUT: - return 'Timed out while fetching your location.' + return 'location.error.timeout' default: - return 'Failed to retrieve your location.' + return 'location.error.generic' } } @@ -33,7 +33,7 @@ export function useUserLocation() { const start = useCallback(() => { if (typeof navigator === 'undefined' || !navigator.geolocation) { - setError('Geolocation is not supported in this browser.') + setError('location.error.unsupported') setIsRequesting(false) return } diff --git a/client/src/lib/i18n.ts b/client/src/lib/i18n.ts new file mode 100644 index 0000000..d5b6ce3 --- /dev/null +++ b/client/src/lib/i18n.ts @@ -0,0 +1,27 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' + +import enCommon from '@/locales/en/common.json' +import frCommon from '@/locales/fr/common.json' + +const browserLanguage = + typeof window !== 'undefined' ? window.navigator.language.split('-')[0]?.toLowerCase() : 'en' + +i18n + .use(initReactI18next) + .init({ + resources: { + en: { common: enCommon }, + fr: { common: frCommon }, + }, + lng: browserLanguage === 'fr' ? 'fr' : 'en', + fallbackLng: 'en', + defaultNS: 'common', + interpolation: { + escapeValue: false, + }, + }) + .catch(() => undefined) + +export { i18n } + diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts index 8106ff1..d0703cb 100644 --- a/client/src/lib/utils.ts +++ b/client/src/lib/utils.ts @@ -10,46 +10,48 @@ export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)) } -export function formatCoordinate(value: number): string { - const formatter = new Intl.NumberFormat('en-US', { +export function formatCoordinate(value: number, locale = 'en-US'): string { + const formatter = new Intl.NumberFormat(locale, { minimumFractionDigits: 3, maximumFractionDigits: 3, }) return formatter.format(value) } -export function formatRelativeTime(dateIso: string): string { +export function formatRelativeTime(dateIso: string, locale = 'en-US'): string { const date = new Date(dateIso) const now = new Date() const diff = Math.max(0, now.getTime() - date.getTime()) const seconds = Math.floor(diff / 1000) + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) + if (seconds < 60) { - return `${seconds}s ago` + return rtf.format(-seconds, 'second') } const minutes = Math.floor(seconds / 60) if (minutes < 60) { - return `${minutes}m ago` + return rtf.format(-minutes, 'minute') } const hours = Math.floor(minutes / 60) if (hours < 24) { - return `${hours}h ago` + return rtf.format(-hours, 'hour') } const days = Math.floor(hours / 24) if (days < 7) { - return `${days}d ago` + return rtf.format(-days, 'day') } const weeks = Math.floor(days / 7) - return `${weeks}w ago` + return rtf.format(-weeks, 'week') } -export function formatTimestamp(dateIso: string): string { +export function formatTimestamp(dateIso: string, locale = 'en-US'): string { const date = new Date(dateIso) - return new Intl.DateTimeFormat('en-US', { + return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', hour: 'numeric', diff --git a/client/src/locales/en/common.json b/client/src/locales/en/common.json new file mode 100644 index 0000000..535f815 --- /dev/null +++ b/client/src/locales/en/common.json @@ -0,0 +1,123 @@ +{ + "app": { + "name": "SignalMap", + "tagline": "Crowd signals around your route" + }, + "status": { + "idle": "Live feed", + "loading": "Syncing map", + "posting": "Sending signal", + "refreshing": "Updating heat", + "error": "Offline" + }, + "header": { + "badge": { + "updated": "{{time}}" + }, + "actions": { + "refresh": "Refresh now", + "focusHeat": "Focus heatmap", + "locate": "Locate me", + "mySignal": "My last signal" + } + }, + "overview": { + "title": "Nearby coverage", + "description": "Signals refresh automatically every few seconds.", + "stats": { + "signals": "Signals", + "contributors": "Contributors" + }, + "badge": "Your last signal {{time}}", + "error": { + "action": "Try again" + }, + "cta": { + "send": "Drop a signal manually", + "sending": "Sending…", + "waiting": "Waiting for location…" + }, + "locationPermission": "Allow location permissions to personalise the feed.", + "lastSynced": "Last synced {{time}}" + }, + "hotspots": { + "title": "Danger zone intel", + "description": "Highest intensity heat within {{radius}}km.", + "noLocation": "{{hint}}", + "empty": "No active hotspots nearby. Tap the map to log a new signal.", + "focus": "Focus", + "itemTitle": "Hotspot #{{index}}", + "itemSubtitleWithDistance": "{{distance}} · {{coordinates}}", + "itemSubtitle": "{{coordinates}}" + }, + "activity": { + "title": "Live community pings", + "description": "Latest activity reported by nearby contributors.", + "empty": "No recent signals within your area yet.", + "view": "View" + }, + "map": { + "posting": "Sending your signal…", + "loading": "Syncing map…", + "confirmationHint": "Confirm the new signal in the dialog to send it." + }, + "dialog": { + "confirmSignal": { + "title": "Confirm new signal", + "description": "You're about to publish a community alert at these coordinates. Double-check the spot before confirming.", + "latitude": "Latitude", + "longitude": "Longitude", + "reach": "Signals are visible to travellers within {{radius}}km and help the community stay aware of hotspots.", + "cancel": "Cancel", + "confirm": "Confirm signal", + "sending": "Sending…" + } + }, + "location": { + "hint": { + "requesting": "Fetching your location…", + "allow": "Allow location access to view nearby reports.", + "showing": "Showing reports within {{radius}}km of you." + }, + "error": { + "permissionDenied": "Location access denied. Enable it to view nearby pings.", + "unavailable": "Unable to determine your position. Try again.", + "timeout": "Timed out while fetching your location.", + "generic": "Failed to retrieve your location.", + "unsupported": "Geolocation is not supported in this browser." + } + }, + "activityItem": { + "user": "User {{id}}", + "distance": "{{distance}} away" + }, + "common": { + "coordinates": "{{lat}}°, {{lng}}°", + "never": "never", + "actions": { + "close": "Close" + }, + "aria": { + "theme": { + "light": "Switch to light mode", + "dark": "Switch to dark mode" + }, + "language": "Change language to {{language}}", + "sheet": { + "close": "Close panel" + } + } + }, + "language": { + "label": "Language", + "english": "English", + "french": "Français" + }, + "errors": { + "feedUnavailable": "Unable to reach the hotspot feed.", + "feedUnknown": "Unknown error while loading hotspots.", + "submitUnavailable": "Unable to store your signal.", + "submitUnknown": "Something went wrong while saving your signal.", + "submitWithReason": "{{message}}" + } +} diff --git a/client/src/locales/fr/common.json b/client/src/locales/fr/common.json new file mode 100644 index 0000000..9435c7a --- /dev/null +++ b/client/src/locales/fr/common.json @@ -0,0 +1,123 @@ +{ + "app": { + "name": "SignalMap", + "tagline": "Alertes de la communauté sur votre itinéraire" + }, + "status": { + "idle": "Flux en direct", + "loading": "Synchronisation de la carte", + "posting": "Envoi du signal", + "refreshing": "Mise à jour de la chaleur", + "error": "Hors ligne" + }, + "header": { + "badge": { + "updated": "{{time}}" + }, + "actions": { + "refresh": "Actualiser", + "focusHeat": "Centrer la chaleur", + "locate": "Me localiser", + "mySignal": "Mon dernier signal" + } + }, + "overview": { + "title": "Couverture à proximité", + "description": "Les signaux se mettent à jour automatiquement toutes les quelques secondes.", + "stats": { + "signals": "Signaux", + "contributors": "Contributeurs" + }, + "badge": "Votre dernier signal {{time}}", + "error": { + "action": "Réessayer" + }, + "cta": { + "send": "Déposer un signal manuellement", + "sending": "Envoi…", + "waiting": "En attente de la localisation…" + }, + "locationPermission": "Autorisez la localisation pour personnaliser le flux.", + "lastSynced": "Dernière synchronisation {{time}}" + }, + "hotspots": { + "title": "Infos sur les zones à risque", + "description": "Chaleur la plus intense dans un rayon de {{radius}} km.", + "noLocation": "{{hint}}", + "empty": "Aucune zone active à proximité. Touchez la carte pour enregistrer un nouveau signal.", + "focus": "Centrer", + "itemTitle": "Zone chaude n°{{index}}", + "itemSubtitleWithDistance": "{{distance}} · {{coordinates}}", + "itemSubtitle": "{{coordinates}}" + }, + "activity": { + "title": "Alertes de la communauté", + "description": "Dernières activités signalées à proximité.", + "empty": "Aucun signal récent dans votre secteur.", + "view": "Voir" + }, + "map": { + "posting": "Envoi de votre signal…", + "loading": "Synchronisation de la carte…", + "confirmationHint": "Confirmez le nouveau signal dans la fenêtre pour l'envoyer." + }, + "dialog": { + "confirmSignal": { + "title": "Confirmer le nouveau signal", + "description": "Vous êtes sur le point de publier une alerte communautaire à ces coordonnées. Vérifiez l'emplacement avant de confirmer.", + "latitude": "Latitude", + "longitude": "Longitude", + "reach": "Les signaux sont visibles par les voyageurs dans un rayon de {{radius}} km et aident la communauté à rester informée des zones à risque.", + "cancel": "Annuler", + "confirm": "Confirmer le signal", + "sending": "Envoi…" + } + }, + "location": { + "hint": { + "requesting": "Récupération de votre localisation…", + "allow": "Autorisez l'accès à la localisation pour voir les signalements à proximité.", + "showing": "Affichage des signalements dans un rayon de {{radius}} km autour de vous." + }, + "error": { + "permissionDenied": "Accès à la localisation refusé. Activez-le pour voir les alertes proches.", + "unavailable": "Impossible de déterminer votre position. Réessayez.", + "timeout": "Délai dépassé lors de la récupération de votre localisation.", + "generic": "Échec de la récupération de votre localisation.", + "unsupported": "La géolocalisation n'est pas prise en charge par ce navigateur." + } + }, + "activityItem": { + "user": "Utilisateur {{id}}", + "distance": "À {{distance}}" + }, + "common": { + "coordinates": "{{lat}}°, {{lng}}°", + "never": "jamais", + "actions": { + "close": "Fermer" + }, + "aria": { + "theme": { + "light": "Passer en mode clair", + "dark": "Passer en mode sombre" + }, + "language": "Changer la langue pour {{language}}", + "sheet": { + "close": "Fermer le panneau" + } + } + }, + "language": { + "label": "Langue", + "english": "Anglais", + "french": "Français" + }, + "errors": { + "feedUnavailable": "Impossible d'atteindre le flux de signaux.", + "feedUnknown": "Erreur inconnue lors du chargement des signaux.", + "submitUnavailable": "Impossible d'enregistrer votre signal.", + "submitUnknown": "Une erreur est survenue lors de l'enregistrement de votre signal.", + "submitWithReason": "{{message}}" + } +} diff --git a/client/src/main.tsx b/client/src/main.tsx index f9078a5..48a499b 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,12 +1,16 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { I18nextProvider } from 'react-i18next' import 'leaflet/dist/leaflet.css' import '@/index.css' import App from '@/App.tsx' +import { i18n } from '@/lib/i18n' createRoot(document.getElementById('root')!).render( - + + + , )