diff --git a/frontend/package.json b/frontend/package.json index 55a084e..3abdc36 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "i18next-locize-backend": "^9.0.1", "js-cookie": "^3.0.5", "leaflet": "^1.9.4", + "openmeteo": "^1.2.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.5.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 0b3a19a..32bc28d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -40,6 +40,9 @@ importers: leaflet: specifier: ^1.9.4 version: 1.9.4 + openmeteo: + specifier: ^1.2.3 + version: 1.2.3 react: specifier: ^18.3.1 version: 18.3.1 @@ -459,6 +462,10 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@openmeteo/sdk@1.26.0': + resolution: {integrity: sha512-e6uSY2O8LEAPX4GjmKswSSE/94II2HV3YJ/G+GoCZ0JyFJe3rtBtZYLRqgJPwp6OISQVduKJdAt5XkpglmSbew==} + engines: {node: '>=12.0'} + '@oxc-parser/binding-android-arm-eabi@0.112.0': resolution: {integrity: sha512-retxBzJ39Da7Lh/eZTn9+HJgTeDUxZIpuI0urOsmcFsBKXAth3lc1jIvwseQ9qbAI/VrsoFOXiGIzgclARbAHg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1187,6 +1194,9 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -1445,6 +1455,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + openmeteo@1.2.3: + resolution: {integrity: sha512-3updmgCMnCAAjAgV3RfhMdIBPi/43RtbjQ4CizHGn52RP9SpgmVCDRpun+Ex75NG3jTF3pZp+d1BPlP9DWMHiQ==} + engines: {node: '>=12.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2065,6 +2079,10 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@openmeteo/sdk@1.26.0': + dependencies: + flatbuffers: 25.9.23 + '@oxc-parser/binding-android-arm-eabi@0.112.0': optional: true @@ -2709,6 +2727,8 @@ snapshots: flatted: 3.3.3 keyv: 4.5.4 + flatbuffers@25.9.23: {} + flatted@3.3.3: {} flowbite-react@0.12.17(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@4.2.1): @@ -2912,6 +2932,11 @@ snapshots: node-releases@2.0.27: {} + openmeteo@1.2.3: + dependencies: + '@openmeteo/sdk': 1.26.0 + flatbuffers: 25.9.23 + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/frontend/src/components/globals/CurrencyRateCard.tsx b/frontend/src/components/globals/CurrencyRateCard.tsx new file mode 100644 index 0000000..3eb8af0 --- /dev/null +++ b/frontend/src/components/globals/CurrencyRateCard.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "react-i18next"; + +import { useCurrency } from "../../context/CurrencyContext"; +import { normalizeLocale, type AppLocale } from "../../i18n/locales"; + +type CurrencyRateCardProps = { + locale?: string | null; +}; + +const localeLabels: Record = { + "en-GB": "English (UK)", + "zh-Hant-HK": "中文(繁體,香港)", +}; + +const localeToTargetCurrency: Record = { + "en-GB": "GBP", + "zh-Hant-HK": "HKD", +}; + +const CurrencyRateCard = ({ locale }: CurrencyRateCardProps) => { + const { t } = useTranslation(); + const { currency, rate } = useCurrency(); + + const resolvedLocale = normalizeLocale(locale) ?? "en-GB"; + const targetCurrency = localeToTargetCurrency[resolvedLocale]; + const targetRate = currency === targetCurrency ? rate : undefined; + + const displayRate = + targetRate ?? + ({ + GBP: 0.58, + HKD: 5.85, + SGD: 1, + } as const)[targetCurrency]; + + return ( +
+
+

+ {t("currencyRate.title", { defaultValue: "Currency Rate" })} +

+ +
+ + {t("currencyRate.locale", { defaultValue: "Locale" })}: {localeLabels[resolvedLocale]} + + + {t("currencyRate.sgd", { defaultValue: "SGD" })}: 1 SGD = {displayRate.toFixed(2)} {targetCurrency} + +
+ +

+ {t("currencyRate.helper", { + defaultValue: "The display follows the current app localisation.", + })} +

+
+
+ ); +}; + +export default CurrencyRateCard; \ No newline at end of file diff --git a/frontend/src/components/globals/WeatherCard.tsx b/frontend/src/components/globals/WeatherCard.tsx new file mode 100644 index 0000000..847280d --- /dev/null +++ b/frontend/src/components/globals/WeatherCard.tsx @@ -0,0 +1,237 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { fetchWeatherApi } from "openmeteo"; + +import { DateTimeDisplay } from "../../utils/formatDateTime"; + +export type WeatherLocation = { + label?: string; + latitude: number; + longitude: number; +}; + +export type WeatherSnapshot = { + time: string; + temperature: number; + apparentTemperature: number; + windSpeed: number; + weatherCode: number; +}; + +type WeatherLocationState = WeatherLocation & { + weather: WeatherSnapshot | null; + loading: boolean; + error: string | null; +}; + +type WeatherCardProps = { + locations?: WeatherLocation[] | null; + title?: string; +}; + +const DEFAULT_LOCATIONS: WeatherLocation[] = [ + { + label: "Singapore", + latitude: 1.3521, + longitude: 103.8198, + }, +]; + +const weatherCodeToLabel = (weatherCode: number) => { + const weatherCodeMap: Record = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Depositing rime fog", + 51: "Light drizzle", + 53: "Moderate drizzle", + 55: "Dense drizzle", + 61: "Slight rain", + 63: "Moderate rain", + 65: "Heavy rain", + 80: "Rain showers", + 95: "Thunderstorm", + }; + + return weatherCodeMap[weatherCode] ?? "Unknown"; +}; + +async function loadCurrentWeather(location: WeatherLocation): Promise { + const responses = await fetchWeatherApi("https://api.open-meteo.com/v1/forecast", { + latitude: location.latitude, + longitude: location.longitude, + current: [ + "temperature_2m", + "apparent_temperature", + "weather_code", + "wind_speed_10m", + ], + timezone: "Asia/Singapore", + }); + + const response = responses[0]; + const current = response.current(); + + if (!current) { + throw new Error("No current weather in response"); + } + + const utcOffsetSeconds = response.utcOffsetSeconds(); + const temperature = current.variables(0)?.value(); + const apparentTemperature = current.variables(1)?.value(); + const weatherCode = current.variables(2)?.value(); + const windSpeed = current.variables(3)?.value(); + + if ( + temperature == null || + apparentTemperature == null || + weatherCode == null || + windSpeed == null + ) { + throw new Error("Incomplete weather data in response"); + } + + return { + time: new Date((Number(current.time()) + utcOffsetSeconds) * 1000).toISOString(), + temperature, + apparentTemperature, + weatherCode, + windSpeed, + }; +} + +const WeatherCard = ({ locations, title }: WeatherCardProps) => { + const { t } = useTranslation(); + const [locationStates, setLocationStates] = useState([]); + + const resolvedLocations = locations?.length ? locations : DEFAULT_LOCATIONS; + + useEffect(() => { + let isCancelled = false; + + async function getWeather() { + setLocationStates( + resolvedLocations.map((location) => ({ + ...location, + weather: null, + loading: true, + error: null, + })), + ); + + const results = await Promise.allSettled( + resolvedLocations.map(async (location) => ({ + location, + weather: await loadCurrentWeather(location), + })), + ); + + if (isCancelled) { + return; + } + + setLocationStates( + results.map((result, index) => { + const location = resolvedLocations[index]; + + if (result.status === "fulfilled") { + return { + ...location, + weather: result.value.weather, + loading: false, + error: null, + }; + } + + return { + ...location, + weather: null, + loading: false, + error: t("weather.loadError", { + defaultValue: "Unable to load weather right now.", + }), + }; + }), + ); + } + + void getWeather(); + + return () => { + isCancelled = true; + }; + }, [resolvedLocations, t]); + + return ( +
+
+

+ {title ?? t("weather.title", { defaultValue: "Current Weather" })} +

+ +
+ {locationStates.map((locationState) => { + const locationLabel = locationState.label ?? t("weather.unnamedLocation", { + defaultValue: "Location", + }); + + return ( +
+
+

{locationLabel}

+ + {locationState.loading + ? t("weather.loading", { defaultValue: "Loading weather..." }) + : null} + +
+ + {locationState.loading && ( +

{t("weather.loading", { defaultValue: "Loading weather..." })}

+ )} + + {locationState.error && !locationState.loading && ( +

{locationState.error}

+ )} + + {locationState.weather && !locationState.loading && !locationState.error && ( + <> +

+ {weatherCodeToLabel(Math.round(locationState.weather.weatherCode))} +

+ +
+ + {locationState.weather.temperature.toFixed(1)}°C + + + {t("weather.feelsLike", { + defaultValue: "Feels like", + })} {locationState.weather.apparentTemperature.toFixed(1)}°C + + + {t("weather.wind", { defaultValue: "Wind" })} {locationState.weather.windSpeed.toFixed(1)} km/h + +
+ +

+ {t("weather.updatedAt", { defaultValue: "Updated at" })} {" "} + +

+ + )} +
+ ); + })} +
+
+
+ ); +}; + +export default WeatherCard; \ No newline at end of file diff --git a/frontend/src/components/navigation/Navbar.tsx b/frontend/src/components/navigation/Navbar.tsx index 6f9a0c7..3209b27 100644 --- a/frontend/src/components/navigation/Navbar.tsx +++ b/frontend/src/components/navigation/Navbar.tsx @@ -1,4 +1,3 @@ -import { CiSearch } from "react-icons/ci"; import { RxHamburgerMenu } from "react-icons/rx"; import logo from "../../assets/logo.png"; @@ -67,13 +66,6 @@ const ProfileDropdown = () => { > {t("navigation:profileMenu.profile")} - setOpen(false)} - > - {t("navigation:profileMenu.settings")} -