diff --git a/.eslintrc.js b/.eslintrc.js
index 601f4a4..8699457 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -4,7 +4,6 @@ module.exports = {
es6: true,
node: true,
},
- extends: ["react-app"],
globals: {
Atomics: "readonly",
SharedArrayBuffer: "readonly",
@@ -15,5 +14,6 @@ module.exports = {
},
ecmaVersion: 2018,
},
+ extends: ["react-app"],
rules: {},
};
diff --git a/src/App.test.js b/src/App.test.js
index 23c181f..0a190c9 100644
--- a/src/App.test.js
+++ b/src/App.test.js
@@ -2,6 +2,19 @@ import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
+const originalFetch = window.fetch;
+const mockFetch = jest.fn(() => new Promise(() => {}));
+
+beforeAll(() => {
+ window.fetch = mockFetch;
+ global.fetch = mockFetch;
+});
+
+afterAll(() => {
+ window.fetch = originalFetch;
+ global.fetch = originalFetch;
+});
+
it("renders without crashing", () => {
const div = document.createElement("div");
ReactDOM.render(, div);
diff --git a/src/components/weatherWidget/WeatherWidget.css b/src/components/weatherWidget/WeatherWidget.css
new file mode 100644
index 0000000..6a9824d
--- /dev/null
+++ b/src/components/weatherWidget/WeatherWidget.css
@@ -0,0 +1,72 @@
+.weather-widget {
+ display: inline-flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ padding: 1rem 1.1rem;
+ margin-top: 1.5rem;
+ border: 1px solid;
+ border-radius: 18px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
+ min-width: 260px;
+ max-width: 360px;
+}
+
+.weather-widget__header {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+.weather-widget__label {
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.weather-widget__location {
+ font-size: 0.95rem;
+ line-height: 1.2;
+}
+
+.weather-widget__state {
+ font-size: 0.95rem;
+ line-height: 1.4;
+}
+
+.weather-widget__content {
+ display: flex;
+ align-items: center;
+ gap: 0.9rem;
+}
+
+.weather-widget__icon {
+ font-size: 2rem;
+ width: 2rem;
+ text-align: center;
+}
+
+.weather-widget__reading {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+}
+
+.weather-widget__temperature {
+ font-size: 2rem;
+ font-weight: 700;
+ line-height: 1;
+ font-family: "Google Sans Bold";
+}
+
+.weather-widget__condition {
+ font-size: 0.95rem;
+ line-height: 1.3;
+}
+
+@media (max-width: 768px) {
+ .weather-widget {
+ width: 100%;
+ max-width: 100%;
+ }
+}
diff --git a/src/components/weatherWidget/WeatherWidget.js b/src/components/weatherWidget/WeatherWidget.js
new file mode 100644
index 0000000..dba219e
--- /dev/null
+++ b/src/components/weatherWidget/WeatherWidget.js
@@ -0,0 +1,148 @@
+import React, { useEffect, useState } from "react";
+import "./WeatherWidget.css";
+
+const weatherCodeMap = {
+ 0: { label: "Clear sky", icon: "fa-sun" },
+ 1: { label: "Mostly clear", icon: "fa-sun" },
+ 2: { label: "Partly cloudy", icon: "fa-cloud-sun" },
+ 3: { label: "Overcast", icon: "fa-cloud" },
+ 45: { label: "Fog", icon: "fa-smog" },
+ 48: { label: "Rime fog", icon: "fa-smog" },
+ 51: { label: "Light drizzle", icon: "fa-cloud-rain" },
+ 53: { label: "Drizzle", icon: "fa-cloud-rain" },
+ 55: { label: "Heavy drizzle", icon: "fa-cloud-showers-heavy" },
+ 61: { label: "Light rain", icon: "fa-cloud-rain" },
+ 63: { label: "Rain", icon: "fa-cloud-showers-heavy" },
+ 65: { label: "Heavy rain", icon: "fa-cloud-showers-heavy" },
+ 71: { label: "Light snow", icon: "fa-snowflake" },
+ 73: { label: "Snow", icon: "fa-snowflake" },
+ 75: { label: "Heavy snow", icon: "fa-snowflake" },
+ 80: { label: "Rain showers", icon: "fa-cloud-showers-heavy" },
+ 81: { label: "Rain showers", icon: "fa-cloud-showers-heavy" },
+ 82: { label: "Violent rain showers", icon: "fa-bolt" },
+ 95: { label: "Thunderstorm", icon: "fa-bolt" },
+ 96: { label: "Thunderstorm with hail", icon: "fa-bolt" },
+ 99: { label: "Thunderstorm with hail", icon: "fa-bolt" },
+};
+
+function getWeatherInfo(weatherCode) {
+ return weatherCodeMap[weatherCode] || {
+ label: "Weather update",
+ icon: "fa-cloud-sun",
+ };
+}
+
+export default function WeatherWidget({
+ theme,
+ label = "Pontianak, West Borneo",
+ latitude = 0.0263,
+ longitude = 109.3425,
+}) {
+ const [status, setStatus] = useState("loading");
+ const [weather, setWeather] = useState(null);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ let isActive = true;
+
+ if (typeof window === "undefined" || typeof window.fetch !== "function") {
+ setStatus("error");
+ setError("Weather data is unavailable in this browser.");
+ return undefined;
+ }
+
+ setStatus("loading");
+ setError("");
+
+ const request = window.fetch(
+ `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true&timezone=Asia%2FJakarta`
+ );
+
+ if (!request || typeof request.then !== "function") {
+ setStatus("error");
+ setError("Weather request could not be started.");
+ return undefined;
+ }
+
+ request
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error(`Weather request failed with status ${response.status}.`);
+ }
+
+ return response.json();
+ })
+ .then((data) => {
+ if (!isActive) {
+ return;
+ }
+
+ if (!data.current_weather) {
+ throw new Error("Weather data is missing from the response.");
+ }
+
+ setWeather(data.current_weather);
+ setStatus("success");
+ })
+ .catch((fetchError) => {
+ if (!isActive) {
+ return;
+ }
+
+ setStatus("error");
+ setError(fetchError.message);
+ });
+
+ return () => {
+ isActive = false;
+ };
+ }, [latitude, longitude]);
+
+ const weatherInfo = weather ? getWeatherInfo(weather.weathercode) : null;
+
+ return (
+
+
+
+ Live temperature (°C)
+
+
+ {label}
+
+
+
+ {status === "loading" && (
+
+ Loading temperature...
+
+ )}
+
+ {status === "error" && (
+
+ {error}
+
+ )}
+
+ {status === "success" && weather && weatherInfo && (
+
+
+
+
+ {Math.round(weather.temperature)}°C
+
+
+ {weatherInfo.label} · Wind {Math.round(weather.windspeed)} km/h
+
+
+
+ )}
+
+ );
+}
diff --git a/src/containers/greeting/Greeting.css b/src/containers/greeting/Greeting.css
index 9fb3139..f32efef 100644
--- a/src/containers/greeting/Greeting.css
+++ b/src/containers/greeting/Greeting.css
@@ -55,6 +55,12 @@
height: auto;
}
+.greeting-weather-div {
+ display: flex;
+ justify-content: center;
+ margin-top: 1rem;
+}
+
/* Media Query */
@media (max-width: 1380px) {
.greeting-text {
@@ -87,6 +93,10 @@
display: block;
}
+ .greeting-weather-div {
+ margin-top: 1.25rem;
+ }
+
.portfolio-repo-btn-div {
width: 100%;
display: flex;
diff --git a/src/containers/greeting/Greeting.js b/src/containers/greeting/Greeting.js
index 7f8d45e..c5945ba 100644
--- a/src/containers/greeting/Greeting.js
+++ b/src/containers/greeting/Greeting.js
@@ -5,6 +5,7 @@ import Button from "../../components/button/Button";
import { greeting } from "../../portfolio";
import { Fade } from "react-reveal";
import FeelingProud from "./FeelingProud";
+import WeatherWidget from "../../components/weatherWidget/WeatherWidget";
export default function Greeting(props) {
const theme = props.theme;
@@ -50,6 +51,9 @@ export default function Greeting(props) {
src={require("../../assests/images/feelingProud.svg")}
> */}
+
+
+