From 65ad968223458df274962ae4c38887fcacb1c3f6 Mon Sep 17 00:00:00 2001 From: lamhung <lamhungypl@gmail.com> Date: Sun, 24 Apr 2022 17:09:24 +0700 Subject: [PATCH] Widget UI and fetched data --- .eslintrc.json | 3 +- .gitignore | 3 + index.html | 6 + package-lock.json | 408 +++++++++++++++++- package.json | 5 +- src/App.css | 8 + src/App.tsx | 26 +- src/core/clients/baseAPI.ts | 17 + src/core/createAPIClient.ts | 65 +++ src/core/createQueryClient.ts | 13 + src/core/environment.ts | 26 ++ src/index.css | 2 +- src/main.tsx | 5 +- src/modules/weather/apis/weatherAPI.ts | 36 ++ .../components/WeatherCard/WeatherCard.tsx | 90 ++++ .../weather/components/WeatherCard/index.ts | 1 + .../components/WeatherIcon/NotFoundIcon.tsx | 42 ++ .../components/WeatherIcon/WeatherIcon.tsx | 18 + .../weather/components/WeatherIcon/index.ts | 1 + .../WeatherNotFound/WeatherNotFound.tsx | 23 + .../components/WeatherNotFound/index.ts | 1 + .../WeatherWidget/WeatherWidget.tsx | 110 +++++ .../weather/components/WeatherWidget/index.ts | 1 + .../components/WeekDayItem/WeekDayItem.tsx | 45 ++ .../weather/components/WeekDayItem/index.ts | 1 + .../contexts/MeasurementUnitContext.tsx | 56 +++ src/modules/weather/contexts/index.ts | 1 + .../weather/schemas/AirPollutionSchema.ts | 19 + .../weather/schemas/WeatherOneCallSchema.ts | 100 +++++ src/modules/weather/schemas/WeatherSchema.ts | 7 + src/modules/weather/schemas/index.ts | 3 + src/modules/weather/services/index.ts | 1 + .../weather/services/useWeatherService.ts | 33 ++ src/modules/weather/utils/conversions.ts | 26 ++ .../weather/utils/transformWeatherData.ts | 80 ++++ src/pages/Home/Home.tsx | 54 +++ src/pages/Home/index.ts | 1 + tsconfig.json | 3 +- vite.config.ts | 7 +- 39 files changed, 1333 insertions(+), 14 deletions(-) create mode 100644 src/core/clients/baseAPI.ts create mode 100644 src/core/createAPIClient.ts create mode 100644 src/core/createQueryClient.ts create mode 100644 src/core/environment.ts create mode 100644 src/modules/weather/apis/weatherAPI.ts create mode 100644 src/modules/weather/components/WeatherCard/WeatherCard.tsx create mode 100644 src/modules/weather/components/WeatherCard/index.ts create mode 100644 src/modules/weather/components/WeatherIcon/NotFoundIcon.tsx create mode 100644 src/modules/weather/components/WeatherIcon/WeatherIcon.tsx create mode 100644 src/modules/weather/components/WeatherIcon/index.ts create mode 100644 src/modules/weather/components/WeatherNotFound/WeatherNotFound.tsx create mode 100644 src/modules/weather/components/WeatherNotFound/index.ts create mode 100644 src/modules/weather/components/WeatherWidget/WeatherWidget.tsx create mode 100644 src/modules/weather/components/WeatherWidget/index.ts create mode 100644 src/modules/weather/components/WeekDayItem/WeekDayItem.tsx create mode 100644 src/modules/weather/components/WeekDayItem/index.ts create mode 100644 src/modules/weather/contexts/MeasurementUnitContext.tsx create mode 100644 src/modules/weather/contexts/index.ts create mode 100644 src/modules/weather/schemas/AirPollutionSchema.ts create mode 100644 src/modules/weather/schemas/WeatherOneCallSchema.ts create mode 100644 src/modules/weather/schemas/WeatherSchema.ts create mode 100644 src/modules/weather/schemas/index.ts create mode 100644 src/modules/weather/services/index.ts create mode 100644 src/modules/weather/services/useWeatherService.ts create mode 100644 src/modules/weather/utils/conversions.ts create mode 100644 src/modules/weather/utils/transformWeatherData.ts create mode 100644 src/pages/Home/Home.tsx create mode 100644 src/pages/Home/index.ts diff --git a/.eslintrc.json b/.eslintrc.json index cb208a3..35efb07 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -24,6 +24,7 @@ } ], "no-console": ["warn"], - "react/display-name": ["off"] + "react/display-name": ["off"], + "@next/next/no-img-element": ["off"] } } diff --git a/.gitignore b/.gitignore index 29083bc..624641b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ sketch .eslintcache + + +.env diff --git a/index.html b/index.html index 38f3861..f5ce0dc 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,12 @@ <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vite App</title> + <link rel="preconnect" href="https://fonts.googleapis.com" /> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> + <link + href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap" + rel="stylesheet" + /> </head> <body> <div id="root"></div> diff --git a/package-lock.json b/package-lock.json index 3b1a255..b37fdf3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,15 @@ "ahooks": "^3.3.8", "axios": "^0.26.1", "clsx": "^1.1.1", + "date-fns": "^2.28.0", "lodash-es": "^4.17.21", "react": "^18.0.0", "react-dom": "^18.0.0", - "react-query": "^3.35.0" + "react-query": "^3.35.0", + "react-router-dom": "^6.3.0" }, "devDependencies": { + "@honkhonk/vite-plugin-svgr": "^1.1.0", "@types/node": "^17.0.25", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", @@ -498,6 +501,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@honkhonk/vite-plugin-svgr": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@honkhonk/vite-plugin-svgr/-/vite-plugin-svgr-1.1.0.tgz", + "integrity": "sha512-Z/KR54UolyaNRlbRWnrXb3C+2Xtl6ilHvD3ceoqTVV69OswtjSGZDefHHNK+SQUSBbYQo0lJO2jLgip0QBxL1A==", + "dev": true, + "dependencies": { + "@svgr/core": "^5.5.0" + }, + "peerDependencies": { + "vite": "^2.5.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.9.5", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", @@ -850,6 +865,198 @@ "integrity": "sha512-ZK5v4bJwgXldAUA8r3q9YKfCwOqoHTK/ZqRjSeRXQrBXWouoPnS4MQtgC4AXGiiBuUu5wxrRgTlv0ktmM4P1Aw==", "dev": true }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", + "integrity": "sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-5.4.0.tgz", + "integrity": "sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-5.0.1.tgz", + "integrity": "sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-5.0.1.tgz", + "integrity": "sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-5.4.0.tgz", + "integrity": "sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-5.4.0.tgz", + "integrity": "sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz", + "integrity": "sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.5.0.tgz", + "integrity": "sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.5.0.tgz", + "integrity": "sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig==", + "dev": true, + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "^5.0.1", + "@svgr/babel-plugin-replace-jsx-attribute-value": "^5.0.1", + "@svgr/babel-plugin-svg-dynamic-title": "^5.4.0", + "@svgr/babel-plugin-svg-em-dimensions": "^5.4.0", + "@svgr/babel-plugin-transform-react-native-svg": "^5.4.0", + "@svgr/babel-plugin-transform-svg-component": "^5.5.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/core": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-5.5.0.tgz", + "integrity": "sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ==", + "dev": true, + "dependencies": { + "@svgr/plugin-jsx": "^5.5.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/core/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.5.0.tgz", + "integrity": "sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.12.6" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.5.0.tgz", + "integrity": "sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@svgr/babel-preset": "^5.5.0", + "@svgr/hast-util-to-babel-ast": "^5.5.0", + "svg-parser": "^2.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, "node_modules/@types/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", @@ -2213,6 +2420,18 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "node_modules/date-fns": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz", + "integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==", + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/dayjs": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.1.tgz", @@ -4373,6 +4592,14 @@ "node": ">=0.10.0" } }, + "node_modules/history": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", + "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", + "dependencies": { + "@babel/runtime": "^7.7.6" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -6894,6 +7121,30 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.3.0.tgz", + "integrity": "sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==", + "dependencies": { + "history": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.3.0.tgz", + "integrity": "sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==", + "dependencies": { + "history": "^5.2.0", + "react-router": "6.3.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -9452,6 +9703,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true + }, "node_modules/svg-tags": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", @@ -10691,6 +10948,15 @@ } } }, + "@honkhonk/vite-plugin-svgr": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@honkhonk/vite-plugin-svgr/-/vite-plugin-svgr-1.1.0.tgz", + "integrity": "sha512-Z/KR54UolyaNRlbRWnrXb3C+2Xtl6ilHvD3ceoqTVV69OswtjSGZDefHHNK+SQUSBbYQo0lJO2jLgip0QBxL1A==", + "dev": true, + "requires": { + "@svgr/core": "^5.5.0" + } + }, "@humanwhocodes/config-array": { "version": "0.9.5", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", @@ -10910,6 +11176,110 @@ "integrity": "sha512-ZK5v4bJwgXldAUA8r3q9YKfCwOqoHTK/ZqRjSeRXQrBXWouoPnS4MQtgC4AXGiiBuUu5wxrRgTlv0ktmM4P1Aw==", "dev": true }, + "@svgr/babel-plugin-add-jsx-attribute": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", + "integrity": "sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg==", + "dev": true + }, + "@svgr/babel-plugin-remove-jsx-attribute": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-5.4.0.tgz", + "integrity": "sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg==", + "dev": true + }, + "@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-5.0.1.tgz", + "integrity": "sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA==", + "dev": true + }, + "@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-5.0.1.tgz", + "integrity": "sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ==", + "dev": true + }, + "@svgr/babel-plugin-svg-dynamic-title": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-5.4.0.tgz", + "integrity": "sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg==", + "dev": true + }, + "@svgr/babel-plugin-svg-em-dimensions": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-5.4.0.tgz", + "integrity": "sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw==", + "dev": true + }, + "@svgr/babel-plugin-transform-react-native-svg": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz", + "integrity": "sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q==", + "dev": true + }, + "@svgr/babel-plugin-transform-svg-component": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.5.0.tgz", + "integrity": "sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ==", + "dev": true + }, + "@svgr/babel-preset": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.5.0.tgz", + "integrity": "sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig==", + "dev": true, + "requires": { + "@svgr/babel-plugin-add-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "^5.0.1", + "@svgr/babel-plugin-replace-jsx-attribute-value": "^5.0.1", + "@svgr/babel-plugin-svg-dynamic-title": "^5.4.0", + "@svgr/babel-plugin-svg-em-dimensions": "^5.4.0", + "@svgr/babel-plugin-transform-react-native-svg": "^5.4.0", + "@svgr/babel-plugin-transform-svg-component": "^5.5.0" + } + }, + "@svgr/core": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-5.5.0.tgz", + "integrity": "sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ==", + "dev": true, + "requires": { + "@svgr/plugin-jsx": "^5.5.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^7.0.0" + }, + "dependencies": { + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true + } + } + }, + "@svgr/hast-util-to-babel-ast": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.5.0.tgz", + "integrity": "sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ==", + "dev": true, + "requires": { + "@babel/types": "^7.12.6" + } + }, + "@svgr/plugin-jsx": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.5.0.tgz", + "integrity": "sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA==", + "dev": true, + "requires": { + "@babel/core": "^7.12.3", + "@svgr/babel-preset": "^5.5.0", + "@svgr/hast-util-to-babel-ast": "^5.5.0", + "svg-parser": "^2.0.2" + } + }, "@types/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", @@ -11912,6 +12282,11 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "date-fns": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz", + "integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==" + }, "dayjs": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.1.tgz", @@ -13483,6 +13858,14 @@ } } }, + "history": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", + "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", + "requires": { + "@babel/runtime": "^7.7.6" + } + }, "hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -15280,6 +15663,23 @@ "integrity": "sha512-suLIhrU2IHKL5JEKR/fAwJv7bbeq4kJ+pJopf77jHwuR+HmJS/HbrPIGsTBUVfw7tXPOmYv7UJ7PCaN49e8x4A==", "dev": true }, + "react-router": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.3.0.tgz", + "integrity": "sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==", + "requires": { + "history": "^5.2.0" + } + }, + "react-router-dom": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.3.0.tgz", + "integrity": "sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==", + "requires": { + "history": "^5.2.0", + "react-router": "6.3.0" + } + }, "read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -17271,6 +17671,12 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, + "svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true + }, "svg-tags": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", diff --git a/package.json b/package.json index f69de1c..111a4fb 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,15 @@ "ahooks": "^3.3.8", "axios": "^0.26.1", "clsx": "^1.1.1", + "date-fns": "^2.28.0", "lodash-es": "^4.17.21", "react": "^18.0.0", "react-dom": "^18.0.0", - "react-query": "^3.35.0" + "react-query": "^3.35.0", + "react-router-dom": "^6.3.0" }, "devDependencies": { + "@honkhonk/vite-plugin-svgr": "^1.1.0", "@types/node": "^17.0.25", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", diff --git a/src/App.css b/src/App.css index e69de29..56e1762 100644 --- a/src/App.css +++ b/src/App.css @@ -0,0 +1,8 @@ +@tailwind base; + +html, +body { + font-family: 'Roboto', sans-serif; +} +@tailwind components; +@tailwind utilities; diff --git a/src/App.tsx b/src/App.tsx index 87afa15..5b52de4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,30 @@ -import { useState } from 'react'; +import { useRef } from 'react'; +import { QueryClientProvider } from 'react-query'; +import { Routes, Route } from 'react-router-dom'; import './App.css'; +import { createQueryClient } from './core/createQueryClient'; +import { MeasurementUnitProvider } from './modules/weather/contexts'; +import Home from './pages/Home'; function App() { - const [count, setCount] = useState(0); + const queryClientRef = useRef(createQueryClient()); return ( - <div className="App"> - <p>Hello Vite + React!</p> - </div> + <QueryClientProvider client={queryClientRef.current}> + <MeasurementUnitProvider> + <Routes> + <Route + path="*" + element={ + <div className="App"> + <Home /> + </div> + } + ></Route> + </Routes> + </MeasurementUnitProvider> + </QueryClientProvider> ); } diff --git a/src/core/clients/baseAPI.ts b/src/core/clients/baseAPI.ts new file mode 100644 index 0000000..59f4998 --- /dev/null +++ b/src/core/clients/baseAPI.ts @@ -0,0 +1,17 @@ +import { createAPIClient } from '../createAPIClient'; +import { environment } from '../environment'; + +export const baseAPI = createAPIClient(environment.endPoint.apiBaseUrl, true, { + maxRedirects: 0, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json; charset=UTF-8', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Credentials': true, + 'Access-Control-Allow-Methods': 'GET, POST', + 'Access-Control-Request-Headers': 'Content-Type', + }, + params: { + appid: environment.app.apiKey, + }, +}); diff --git a/src/core/createAPIClient.ts b/src/core/createAPIClient.ts new file mode 100644 index 0000000..848f8cd --- /dev/null +++ b/src/core/createAPIClient.ts @@ -0,0 +1,65 @@ +import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; + +export interface ErrorDataModel { + code: number; + description: string; + message: string; + name: string; +} + +export interface ErrorModel<T = ErrorDataModel> { + data: T | undefined; + message: string; + status: number | undefined; +} + +const axiosResponseToData = <T>(res: AxiosResponse<T>): T => { + return res.data; +}; +const axiosErrorToData = <T>({ + response, + message, +}: AxiosError<T>): ErrorModel<T> => { + return { + data: response?.data, + message, + status: response?.status, + }; +}; + +const axiosErrorRedirects = ({ response }: AxiosError) => { + if (response?.status === 302) { + const location = (response.headers as Record<string, string>)['location']; + window.location.replace(location); + } +}; + +export const createAPIClient = ( + baseURL: string, + withCredentials = false, + config?: AxiosRequestConfig, +) => { + const request = axios.create({ + baseURL, + withCredentials, + ...config, + }); + + return { + async get<Response, Params = Record<string, any>, Error = void>( + url: string, + params?: Params, + ) { + try { + const res = await request.get<Response>(url, { + params, + }); + return axiosResponseToData(res); + } catch (error) { + axiosErrorRedirects(error as AxiosError); + + return Promise.reject(axiosErrorToData<Error>(error as AxiosError)); + } + }, + }; +}; diff --git a/src/core/createQueryClient.ts b/src/core/createQueryClient.ts new file mode 100644 index 0000000..ef55f52 --- /dev/null +++ b/src/core/createQueryClient.ts @@ -0,0 +1,13 @@ +import { QueryClient } from 'react-query'; + +export const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + }, + }, + }); diff --git a/src/core/environment.ts b/src/core/environment.ts new file mode 100644 index 0000000..9d4def9 --- /dev/null +++ b/src/core/environment.ts @@ -0,0 +1,26 @@ +export interface AppEnvironment { + app: { + mode: string; + apiKey: string; + }; + + endPoint: { + apiBaseUrl: string; + }; +} + +const envMode = import.meta.env.MODE; + +const apiBaseUrl = import.meta.env.VITE_BASE_URL || ''; +const apiKey = import.meta.env.VITE_API_KEY || ''; + +export const environment: AppEnvironment = { + app: { + mode: envMode, + apiKey, + }, + + endPoint: { + apiBaseUrl, + }, +}; diff --git a/src/index.css b/src/index.css index ec2585e..1754cca 100644 --- a/src/index.css +++ b/src/index.css @@ -8,6 +8,6 @@ body { } code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + font-family: 'source-code-pro', 'Menlo', 'Monaco', 'Consolas', 'Courier New', monospace; } diff --git a/src/main.tsx b/src/main.tsx index c0761b9..80bf542 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,11 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; import App from './App'; import './index.css'; ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> - <App /> + <BrowserRouter> + <App /> + </BrowserRouter> </React.StrictMode>, ); diff --git a/src/modules/weather/apis/weatherAPI.ts b/src/modules/weather/apis/weatherAPI.ts new file mode 100644 index 0000000..4af2eb5 --- /dev/null +++ b/src/modules/weather/apis/weatherAPI.ts @@ -0,0 +1,36 @@ +import { environment } from '../../../core/environment'; +import { + GeocodingData, + AirPollutionData, + WeatherOneCallData, +} from '../schemas'; + +//FIXME: CORS error when using axios client +export const getGeocoding = async ( + city: string, + limit?: number, +): Promise<GeocodingData[]> => { + const response = await fetch( + `${environment.endPoint.apiBaseUrl}/geo/1.0/direct?q=${city}&limit=${ + limit || 1 + }&appId=${environment.app.apiKey}`, + ); + return (await response.json()) as GeocodingData[]; +}; +export const getWeatherOneCall = async ( + lat: number, + lon: number, + part?: string, +) => { + const response = await fetch( + `${environment.endPoint.apiBaseUrl}/data/2.5/onecall?lat=${lat}&lon=${lon}&exclude=${part}&appid=${environment.app.apiKey}&units=metric`, + ); + return (await response.json()) as WeatherOneCallData; +}; + +export const getAirPollution = async (lat: number, lon: number) => { + const response = await fetch( + `${environment.endPoint.apiBaseUrl}/data/2.5/air_pollution?lat=${lat}&lon=${lon}&appid=${environment.app.apiKey}`, + ); + return (await response.json()) as AirPollutionData; +}; diff --git a/src/modules/weather/components/WeatherCard/WeatherCard.tsx b/src/modules/weather/components/WeatherCard/WeatherCard.tsx new file mode 100644 index 0000000..9308566 --- /dev/null +++ b/src/modules/weather/components/WeatherCard/WeatherCard.tsx @@ -0,0 +1,90 @@ +import { isSameDay } from 'date-fns'; +import { first } from 'lodash'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useMeasurementUnitDispatch } from '../../contexts'; +import { + WeatherOneCallData, + AirPollutionData, + GeocodingData, + Current, + Daily, +} from '../../schemas'; +import { transformWeatherData } from '../../utils/transformWeatherData'; +import WeatherWidget from '../WeatherWidget'; +import { WeatherWidgetData } from '../WeatherWidget/WeatherWidget'; +import WeekDayItem from '../WeekDayItem'; + +export interface WeatherCardProps { + geocoding: GeocodingData; + weatherCurrent: WeatherOneCallData; + airPollution: AirPollutionData; +} + +const WeatherCard = ({ + geocoding, + weatherCurrent, + airPollution, +}: WeatherCardProps) => { + const { daily } = weatherCurrent; + + const [selectedData, setSelectedData] = useState<Current | Daily>( + weatherCurrent.current, + ); + + const data: WeatherWidgetData = transformWeatherData({ + geocoding, + weatherData: selectedData, + airPollution, + }); + + const checkSameDay = useMemo(() => { + return isSameDay( + new Date(weatherCurrent.current.dt * 1000), + new Date(selectedData.dt * 1000), + ); + }, [selectedData.dt, weatherCurrent]); + + const handleClickWeekdayItem = useCallback( + (item: Daily) => { + if ( + isSameDay( + new Date(weatherCurrent.current.dt * 1000), + new Date(item.dt * 1000), + ) + ) { + setSelectedData(weatherCurrent.current); + } else { + setSelectedData(item); + } + }, + [weatherCurrent], + ); + + useEffect(() => { + setSelectedData(weatherCurrent.current); + }, [weatherCurrent]); + + return ( + <div className="bg-white border border-[rgba(150_150_150_0.3)] shadow-[0px_2px_2px_rgba(0_0_0_0.25)] rounded-[4px]"> + <WeatherWidget data={data} isCurrent={checkSameDay} /> + <div className="flex items-stretch justify-between"> + {daily.map((dayItem, index) => { + return ( + <WeekDayItem + key={index} + data={dayItem} + onClickItem={handleClickWeekdayItem} + isActive={isSameDay( + new Date(selectedData.dt * 1000), + new Date(dayItem.dt * 1000), + )} + /> + ); + })} + </div> + </div> + ); +}; + +export default WeatherCard; diff --git a/src/modules/weather/components/WeatherCard/index.ts b/src/modules/weather/components/WeatherCard/index.ts new file mode 100644 index 0000000..cfe6499 --- /dev/null +++ b/src/modules/weather/components/WeatherCard/index.ts @@ -0,0 +1 @@ +export { default } from './WeatherCard'; diff --git a/src/modules/weather/components/WeatherIcon/NotFoundIcon.tsx b/src/modules/weather/components/WeatherIcon/NotFoundIcon.tsx new file mode 100644 index 0000000..7dfbc4f --- /dev/null +++ b/src/modules/weather/components/WeatherIcon/NotFoundIcon.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +const NotFoundIcon = ( + props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>, +) => { + return ( + <svg + width="158" + height="158" + viewBox="0 0 158 158" + fill="none" + xmlns="http://www.w3.org/2000/svg" + {...props} + > + <g clipPath="url(#clip0_108_0)"> + <path + d="M-0.00991821 158V0H157.987V158H-0.00991821ZM60.4266 55.248C58.5745 54.819 56.6112 54.2418 54.5923 53.9085C40.1731 51.5319 25.9175 61.9086 23.8461 76.3317C23.6517 77.7052 23.0929 78.1836 21.8859 78.591C8.98242 82.9121 1.24956 96.7457 4.30874 109.913C7.27222 122.672 18.0426 131.32 31.1653 131.336C63.0639 131.367 94.9625 131.367 126.861 131.336C128.251 131.36 129.64 131.266 131.013 131.055C141.098 129.286 148.439 123.805 152.292 114.302C156.224 104.595 154.625 95.4525 148.254 87.1714C147.575 86.2918 147.47 85.5572 147.757 84.514C149.609 77.7237 150.066 70.81 148.51 63.9087C144.222 44.8743 132.634 32.3617 113.683 27.9666C94.7321 23.5714 78.9237 29.9111 66.7858 45.041C64.31 48.1306 62.5536 51.8004 60.4359 55.248H60.4266Z" + fill="white" + /> + <path + d="M60.4366 55.248C62.5542 51.8004 64.3107 48.1306 66.7895 45.041C78.9275 29.9172 94.7451 23.5714 113.687 27.9666C132.628 32.3617 144.226 44.8743 148.514 63.9087C150.057 70.81 149.628 77.7114 147.761 84.514C147.473 85.5572 147.578 86.2918 148.258 87.1715C154.629 95.4525 156.228 104.595 152.295 114.302C148.443 123.805 141.102 129.286 131.017 131.055C129.643 131.266 128.254 131.36 126.865 131.336C94.9663 131.348 63.0677 131.348 31.1691 131.336C18.0526 131.32 7.28215 122.672 4.31249 109.913C1.25331 96.7457 8.98613 82.9214 21.8896 78.591C23.0966 78.1867 23.6554 77.7052 23.8499 76.3317C25.9212 61.9086 40.1768 51.5319 54.596 53.9085C56.6211 54.2418 58.5967 54.819 60.4366 55.248ZM78.8256 123.935C94.7728 123.935 110.72 123.993 126.674 123.913C135.388 123.87 141.735 119.792 145.325 111.876C148.684 104.453 146.949 96.221 140.979 90.1592C139.435 88.5851 139.188 86.9492 139.886 84.9615C142.448 77.6682 142.825 70.2205 140.688 62.816C136.53 48.4207 127.174 38.8773 112.619 35.2846C97.9771 31.6704 85.2959 35.9205 74.8959 46.8065C70.9662 50.9208 68.299 55.819 66.5858 61.245C65.9159 63.3685 64.6503 64.5321 62.9308 64.4025C61.9584 64.3253 60.9613 63.8685 60.0816 63.3901C47.1688 56.3962 31.1907 65.5198 30.8758 80.0942C30.8048 83.3134 30.0176 84.2455 26.8628 84.8659C16.1356 86.9708 9.1127 97.8291 11.5884 108.45C13.7493 117.709 21.5933 123.916 31.3049 123.938C47.1348 123.962 62.9802 123.935 78.8256 123.935Z" + fill="#555555" + /> + <path + d="M78.8255 123.935C62.9802 123.935 47.1348 123.962 31.2863 123.935C21.5747 123.913 13.7277 117.697 11.5699 108.447C9.10033 97.8167 16.117 86.9678 26.8442 84.8628C30.0083 84.2455 30.7955 83.3195 30.8572 80.0911C31.1659 65.5167 47.1502 56.3931 60.063 63.387C60.9428 63.8654 61.9398 64.313 62.9122 64.3994C64.6317 64.529 65.8974 63.3654 66.5672 61.2419C68.2805 55.8159 70.9476 50.9177 74.8773 46.8034C85.2773 35.9174 97.9585 31.6796 112.6 35.2816C127.155 38.8742 136.512 48.4176 140.67 62.813C142.806 70.2205 142.429 77.6651 139.867 84.9585C139.169 86.9462 139.41 88.582 140.96 90.1561C146.93 96.2179 148.677 104.45 145.306 111.873C141.716 119.805 135.369 123.882 126.655 123.91C110.72 123.993 94.7605 123.935 78.8255 123.935ZM89.2379 86.0943C88.4538 85.2085 87.7716 84.443 87.0986 83.6745C83.2955 79.3534 79.4944 75.0251 75.6954 70.6897C74.0315 68.8038 71.7811 68.5507 70.1388 70.0322C68.4966 71.5138 68.4595 73.6311 70.0894 75.5107C72.1762 77.9213 74.2908 80.3071 76.393 82.7053L83.6474 90.9863C83.0609 91.511 82.5731 91.9555 82.0761 92.3938C77.3963 96.4957 72.7041 100.585 68.0459 104.709C66.3203 106.252 66.1628 108.465 67.5643 110.061C68.9658 111.657 71.1298 111.771 72.9264 110.268C73.7135 109.607 74.4699 108.916 75.2478 108.237L88.5433 96.5791C89.1144 97.218 89.6299 97.7797 90.1331 98.3538C94.069 102.838 97.974 107.351 101.965 111.783C102.679 112.57 103.586 113.157 104.596 113.487C106.068 113.928 107.611 113.067 108.383 111.74C109.229 110.286 109.084 108.737 107.852 107.326C103.327 102.138 98.7735 96.968 94.1431 91.6901C94.6339 91.2364 95.0691 90.8166 95.5291 90.4184C100.16 86.3443 104.833 82.3041 109.42 78.1713C110.199 77.4503 110.763 76.5286 111.05 75.5077C111.449 74.0169 110.544 72.5107 109.198 71.7792C107.723 70.9829 106.25 71.1773 104.799 72.4459C99.6533 76.9521 94.5166 81.4676 89.2379 86.0943Z" + fill="white" + /> + <path + d="M89.2379 86.0943C94.5166 81.4645 99.6533 76.9521 104.805 72.4551C106.256 71.1866 107.729 70.9921 109.204 71.7884C110.553 72.5199 111.458 74.0261 111.057 75.5169C110.769 76.5378 110.205 77.4596 109.427 78.1805C104.843 82.3133 100.166 86.3535 95.5353 90.4277C95.0784 90.8258 94.6401 91.2456 94.1493 91.6993C98.7797 96.9679 103.321 102.138 107.846 107.326C109.081 108.737 109.223 110.286 108.377 111.74C107.605 113.067 106.062 113.928 104.589 113.487C103.579 113.157 102.673 112.57 101.959 111.783C97.9678 107.351 94.0628 102.832 90.1269 98.3538C89.6238 97.7797 89.1082 97.2179 88.5371 96.579L75.2416 108.237C74.4699 108.916 73.6981 109.607 72.9202 110.268C71.1236 111.771 68.9843 111.681 67.5582 110.061C66.132 108.44 66.3234 106.237 68.0397 104.709C72.6979 100.585 77.3901 96.4957 82.07 92.3938C82.567 91.9555 83.0547 91.511 83.6412 90.9863L76.3838 82.7053C74.2816 80.3071 72.167 77.9213 70.0802 75.5107C68.4503 73.6311 68.4842 71.5199 70.1296 70.0322C71.7749 68.5445 74.0253 68.7976 75.6861 70.6896C79.4913 75.0107 83.2924 79.339 87.0894 83.6745C87.7716 84.443 88.4538 85.2084 89.2379 86.0943Z" + fill="#555555" + /> + </g> + <defs> + <clipPath id="clip0_108_0"> + <rect width="158" height="158" fill="white" /> + </clipPath> + </defs> + </svg> + ); +}; + +export default NotFoundIcon; diff --git a/src/modules/weather/components/WeatherIcon/WeatherIcon.tsx b/src/modules/weather/components/WeatherIcon/WeatherIcon.tsx new file mode 100644 index 0000000..7a54c09 --- /dev/null +++ b/src/modules/weather/components/WeatherIcon/WeatherIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +type NativeImageProps = JSX.IntrinsicAttributes & + React.ClassAttributes<HTMLImageElement> & + React.ImgHTMLAttributes<HTMLImageElement>; + +interface WeatherIconProps extends NativeImageProps { + icon: string; + size?: '1x' | '2x'; +} + +const WeatherIcon = ({ icon, size = '1x', ...rest }: WeatherIconProps) => { + const iconUrl = size === '1x' ? icon : `${icon}@${size}`; + const imgSrc = `http://openweathermap.org/img/wn/${iconUrl}.png`; + return <img alt="weather-icon" src={imgSrc} {...rest} />; +}; + +export default WeatherIcon; diff --git a/src/modules/weather/components/WeatherIcon/index.ts b/src/modules/weather/components/WeatherIcon/index.ts new file mode 100644 index 0000000..81e956a --- /dev/null +++ b/src/modules/weather/components/WeatherIcon/index.ts @@ -0,0 +1 @@ +export { default } from './WeatherIcon'; diff --git a/src/modules/weather/components/WeatherNotFound/WeatherNotFound.tsx b/src/modules/weather/components/WeatherNotFound/WeatherNotFound.tsx new file mode 100644 index 0000000..c7cb56a --- /dev/null +++ b/src/modules/weather/components/WeatherNotFound/WeatherNotFound.tsx @@ -0,0 +1,23 @@ +import clsx from 'clsx'; + +import NotFoundIcon from '../WeatherIcon/NotFoundIcon'; + +type Props = {}; + +const WeatherNotFound = (props: Props) => { + return ( + <div + className={clsx( + 'bg-white flex flex-col items-center p-8', + 'border border-[rgba(150_150_150_0.3)] shadow-[0px_2px_2px_rgba(0_0_0_0.25)] rounded-[4px]', + )} + > + <NotFoundIcon className="w-40 h-40" /> + <p className="text-center mt-4"> + We could not find weather information for the location above + </p> + </div> + ); +}; + +export default WeatherNotFound; diff --git a/src/modules/weather/components/WeatherNotFound/index.ts b/src/modules/weather/components/WeatherNotFound/index.ts new file mode 100644 index 0000000..3dfc424 --- /dev/null +++ b/src/modules/weather/components/WeatherNotFound/index.ts @@ -0,0 +1 @@ +export { default } from './WeatherNotFound'; diff --git a/src/modules/weather/components/WeatherWidget/WeatherWidget.tsx b/src/modules/weather/components/WeatherWidget/WeatherWidget.tsx new file mode 100644 index 0000000..0198a3b --- /dev/null +++ b/src/modules/weather/components/WeatherWidget/WeatherWidget.tsx @@ -0,0 +1,110 @@ +import clsx from 'clsx'; +import { format } from 'date-fns'; +import React from 'react'; + +import { + MeasurementUnit, + useMeasurementUnitDispatch, + useMeasurementUnitValue, +} from '../../contexts'; +import { temperatureRounded } from '../../utils/conversions'; +import { + degreeToDescription, + qualityIndexToQualitativeName, +} from '../../utils/transformWeatherData'; +import WeatherIcon from '../WeatherIcon'; + +export interface WeatherWidgetData { + dt: number; + name: string; + country: string; + description: string; + icon: string; + temp: number; + wind_speed: number; + wind_deg: number; + aqi: number; + humidity: number; +} +interface WeatherWidgetProps { + data: WeatherWidgetData; + isCurrent: boolean; +} +const WeatherWidget = ({ data, isCurrent }: WeatherWidgetProps) => { + const unit = useMeasurementUnitValue(); + const changeUnit = useMeasurementUnitDispatch(); + + const { + dt, + name, + country, + description, + icon, + temp, + wind_speed, + wind_deg, + aqi, + humidity, + } = data; + + const handleChangeUnitImperial = () => { + changeUnit(MeasurementUnit.Imperial); + }; + const handleChangeUnitMetric = () => { + changeUnit(MeasurementUnit.Metric); + }; + return ( + <div> + <div className="p-5"> + <div className="font-bold">{`${name}, ${country}`}</div> + <div className="text-[#666]"> + {`${format(new Date(dt * 1000), 'EEEE ha')} • ${description}`} + </div> + </div> + <div className="flex px-5 pt-2 pb-4"> + <div className="flex-grow"> + <div className="flex items-center space-x-4"> + <div> + <WeatherIcon className="w-16 h-16" icon={icon} size="2x" /> + </div> + <div className="flex items-start"> + <div className="font-bold text-[44px]"> + {temperatureRounded(temp)}° + </div> + <div className="font-bold flex items-center"> + <div + className={clsx('cursor-pointer', { + 'text-[#888888]': unit !== MeasurementUnit.Imperial, + })} + onClick={handleChangeUnitImperial} + > + F + </div> + <span className="mx-2">{'/'}</span> + <div + className={clsx('cursor-pointer', { + 'text-[#888888]': unit !== MeasurementUnit.Metric, + })} + onClick={handleChangeUnitMetric} + > + C + </div> + </div> + </div> + </div> + </div> + <div className="flex-grow flex flex-col justify-end items-start"> + <div>Humidity: {humidity}%</div> + <div> + Wind: {wind_speed} M/S {degreeToDescription(wind_deg)} + </div> + {isCurrent && ( + <div>Air Quality: {qualityIndexToQualitativeName(aqi)}</div> + )} + </div> + </div> + </div> + ); +}; + +export default WeatherWidget; diff --git a/src/modules/weather/components/WeatherWidget/index.ts b/src/modules/weather/components/WeatherWidget/index.ts new file mode 100644 index 0000000..3833d1e --- /dev/null +++ b/src/modules/weather/components/WeatherWidget/index.ts @@ -0,0 +1 @@ +export { default } from './WeatherWidget'; diff --git a/src/modules/weather/components/WeekDayItem/WeekDayItem.tsx b/src/modules/weather/components/WeekDayItem/WeekDayItem.tsx new file mode 100644 index 0000000..47e8d2c --- /dev/null +++ b/src/modules/weather/components/WeekDayItem/WeekDayItem.tsx @@ -0,0 +1,45 @@ +import clsx from 'clsx'; +import { format } from 'date-fns'; +import React from 'react'; + +import { Daily } from '../../schemas'; +import { temperatureRounded } from '../../utils/conversions'; +import WeatherIcon from '../WeatherIcon/WeatherIcon'; +interface WeekDayItemProps { + isActive?: boolean; + data: Daily; + onClickItem: (item) => void; +} + +const WeekDayItem = ({ isActive, data, onClickItem }: WeekDayItemProps) => { + const { + dt, + temp: { max, min }, + weather, + } = data; + const { icon } = weather[0]; + + const handleClickItem = () => { + onClickItem(data); + }; + + return ( + <div + onClick={handleClickItem} + className={clsx( + 'flex flex-col items-center flex-grow cursor-pointer font-bold', + 'pt-5 pb-4 border border-[#969696] hover:bg-[#f7f7f7CC]', + { + 'bg-[#F7F7F7]': isActive, + }, + )} + > + <div>{format(new Date(dt * 1000), 'EEE')}</div> + <WeatherIcon className="my-3" icon={icon} /> + <p className="font-bold text-[18px]">{temperatureRounded(max)}°</p> + <p>{temperatureRounded(min)}°</p> + </div> + ); +}; + +export default React.memo(WeekDayItem); diff --git a/src/modules/weather/components/WeekDayItem/index.ts b/src/modules/weather/components/WeekDayItem/index.ts new file mode 100644 index 0000000..02b940a --- /dev/null +++ b/src/modules/weather/components/WeekDayItem/index.ts @@ -0,0 +1 @@ +export { default } from './WeekDayItem'; diff --git a/src/modules/weather/contexts/MeasurementUnitContext.tsx b/src/modules/weather/contexts/MeasurementUnitContext.tsx new file mode 100644 index 0000000..4078379 --- /dev/null +++ b/src/modules/weather/contexts/MeasurementUnitContext.tsx @@ -0,0 +1,56 @@ +import React, { + createContext, + FC, + ReactNode, + useContext, + useState, +} from 'react'; + +import { environment } from '../../../core/environment'; + +export enum MeasurementUnit { + Standard = 'Standard', // Kelvin and meter/sec + Metric = 'Metric', // Celsius and meter/sec + Imperial = 'Imperial', // Fahrenheit and miles/hour +} + +export type MeasurementUnitDispatch = (value: MeasurementUnit) => void; + +const MeasurementUnitValueContext = createContext<MeasurementUnit>( + MeasurementUnit.Metric, +); + +export const MeasurementUnitDispatchCtx = + createContext<MeasurementUnitDispatch>(() => {}); + +interface Props { + children?: ReactNode; +} + +export const MeasurementUnitProvider: FC<Props> = ({ children }) => { + const [unit, setUnit] = useState<MeasurementUnit>(MeasurementUnit.Metric); + return ( + <MeasurementUnitValueContext.Provider value={unit}> + <MeasurementUnitDispatchCtx.Provider value={setUnit}> + {children} + </MeasurementUnitDispatchCtx.Provider> + </MeasurementUnitValueContext.Provider> + ); +}; + +export const useMeasurementUnitValue = () => { + const value = useContext<MeasurementUnit>(MeasurementUnitValueContext); + if (!value && environment.app.mode === 'development') { + throw Error('useContext must be used within a Provider'); + } + return value; +}; +export const useMeasurementUnitDispatch = () => { + const dispatch = useContext<MeasurementUnitDispatch>( + MeasurementUnitDispatchCtx, + ); + if (!dispatch && environment.app.mode === 'development') { + throw Error('useContext must be used within a Provider'); + } + return dispatch; +}; diff --git a/src/modules/weather/contexts/index.ts b/src/modules/weather/contexts/index.ts new file mode 100644 index 0000000..0d3070d --- /dev/null +++ b/src/modules/weather/contexts/index.ts @@ -0,0 +1 @@ +export * from './MeasurementUnitContext'; diff --git a/src/modules/weather/schemas/AirPollutionSchema.ts b/src/modules/weather/schemas/AirPollutionSchema.ts new file mode 100644 index 0000000..ff8960e --- /dev/null +++ b/src/modules/weather/schemas/AirPollutionSchema.ts @@ -0,0 +1,19 @@ +export interface AirPollutionData { + coord: number[]; + list: { + dt: string; + main: { + aqi: number; + }; + components: { + co: string; + no: string; + no2: string; + o3: string; + so2: string; + pm2_5: string; + pm10: string; + nh3: string; + }; + }[]; +} diff --git a/src/modules/weather/schemas/WeatherOneCallSchema.ts b/src/modules/weather/schemas/WeatherOneCallSchema.ts new file mode 100644 index 0000000..866c128 --- /dev/null +++ b/src/modules/weather/schemas/WeatherOneCallSchema.ts @@ -0,0 +1,100 @@ +interface Temp { + morn: string; + day: string; + eve: string; + night: string; + min: number; + max: number; +} +interface FeelsLike { + morn: string; + day: string; + eve: string; + night: string; + min: string; + max: string; +} + +interface Rain { + '1h': string; +} +interface Snow { + '1h': string; +} + +interface Weather { + id: string; + main: string; + description: string; + icon: string; +} + +interface DataBlock { + dt: number; + sunrise: number; + sunset: number; + temp: number | Temp; + feels_like: number | FeelsLike; + pressure: number; + humidity: number; + dew_point: number; + wind_speed: number; + wind_gust: number; + wind_deg: number; + weather: Weather[]; + clouds: number; + uvi: number; + rain: string | Rain; + snow: string | Snow; +} + +interface Minutely { + dt: string; + precipitation: string; +} + +interface Hourly extends DataBlock { + feels_like: number; + visibility: string; + pop: string; + rain: Rain; + snow: Snow; +} + +export interface Daily extends DataBlock { + moonrise: string; + moonset: string; + moon_phase: string; + temp: Temp; + feels_like: FeelsLike; + pop: string; + rain: string; + snow: string; +} + +export interface Current extends DataBlock { + temp: number; + feels_like: number; + visibility: number; + rain: Rain; + snow: Snow; +} +interface Alerts { + sender_name: string; + event: string; + start: string; + end: string; + description: string; + tags: string; +} +export interface WeatherOneCallData { + lat: number; + lon: number; + timezone: string; + timezone_offset: string; + current: Current; + minutely: Minutely; + hourly: Hourly[]; + daily: Daily[]; + alerts: Alerts; +} diff --git a/src/modules/weather/schemas/WeatherSchema.ts b/src/modules/weather/schemas/WeatherSchema.ts new file mode 100644 index 0000000..d49ea54 --- /dev/null +++ b/src/modules/weather/schemas/WeatherSchema.ts @@ -0,0 +1,7 @@ +export interface GeocodingData { + name: string; + local_names: { [key: string]: string }; + lat: number; + lon: number; + country: string; +} diff --git a/src/modules/weather/schemas/index.ts b/src/modules/weather/schemas/index.ts new file mode 100644 index 0000000..b20cf0c --- /dev/null +++ b/src/modules/weather/schemas/index.ts @@ -0,0 +1,3 @@ +export * from './WeatherSchema'; +export * from './AirPollutionSchema'; +export * from './WeatherOneCallSchema'; diff --git a/src/modules/weather/services/index.ts b/src/modules/weather/services/index.ts new file mode 100644 index 0000000..159fed3 --- /dev/null +++ b/src/modules/weather/services/index.ts @@ -0,0 +1 @@ +export * from './useWeatherService'; diff --git a/src/modules/weather/services/useWeatherService.ts b/src/modules/weather/services/useWeatherService.ts new file mode 100644 index 0000000..ce19be2 --- /dev/null +++ b/src/modules/weather/services/useWeatherService.ts @@ -0,0 +1,33 @@ +import { useQuery } from 'react-query'; + +import { + getAirPollution, + getGeocoding, + getWeatherOneCall, +} from '../apis/weatherAPI'; + +interface UseWeatherServiceProps { + city: string; +} + +const WEATHER = 'WEATHER'; + +export const useWeatherService = ({ city }: UseWeatherServiceProps) => { + return useQuery( + [WEATHER, city], + async () => { + const [geocodeData] = await getGeocoding(city); + const { lat, lon } = geocodeData; + const [weatherOneCallData, airPollutionData] = await Promise.all([ + getWeatherOneCall(lat, lon), + getAirPollution(lat, lon), + ]); + return { + geocoding: geocodeData, + weatherOneCall: weatherOneCallData, + airPollution: airPollutionData, + }; + }, + { enabled: !!city, keepPreviousData: true }, + ); +}; diff --git a/src/modules/weather/utils/conversions.ts b/src/modules/weather/utils/conversions.ts new file mode 100644 index 0000000..42406fe --- /dev/null +++ b/src/modules/weather/utils/conversions.ts @@ -0,0 +1,26 @@ +export const temperatureRounded = ( + temp: number, + unit: 'metric' | 'imperial' | undefined = 'metric', +) => { + return Math.round(temp); +}; + +export function kelvinToCelsius(k: number) { + return Math.round(k - 273.15); +} + +export function celsiusToFahrenheit(c: number) { + return Math.round(c * (9 / 5) + 32); +} + +export function fahrenheitToCelsius(f: number) { + return Math.round(((f - 32) * 5) / 9); +} + +export function kmToMile(n: number) { + return Math.round(n / 1.60934); +} + +export function mileToKm(n: number) { + return Math.round(n * 1.60934); +} diff --git a/src/modules/weather/utils/transformWeatherData.ts b/src/modules/weather/utils/transformWeatherData.ts new file mode 100644 index 0000000..10cc401 --- /dev/null +++ b/src/modules/weather/utils/transformWeatherData.ts @@ -0,0 +1,80 @@ +import { WeatherWidgetData } from '../components/WeatherWidget/WeatherWidget'; +import { + AirPollutionData, + Current, + Daily, + GeocodingData, + WeatherOneCallData, +} from '../schemas'; + +export enum DegreeDescription { + North = 'North', + East = 'East', + South = 'South', + West = 'West', + North_East = 'North East', + North_West = 'North West', + South_East = 'South East', + South_West = 'North', +} + +export const degreeToDescription = (degree: number) => { + switch (degree) { + default: + return DegreeDescription.North_East; + } +}; + +export const qualityIndexToQualitativeName = (index) => { + switch (index) { + case 1: { + return 'Good'; + } + case 2: { + return 'Fair'; + } + case 3: { + return 'Moderate'; + } + case 4: { + return 'Poor'; + } + case 5: { + return 'Very Poor'; + } + + default: + return 'Moderate'; + } +}; + +export const transformWeatherData = ({ + geocoding, + weatherData, + airPollution, +}: { + geocoding: GeocodingData; + weatherData: Current | Daily; + airPollution: AirPollutionData; +}): WeatherWidgetData => { + const { name, country } = geocoding; + const { dt, weather, temp, humidity, wind_speed, wind_deg } = weatherData; + const { list } = airPollution; + const { description, icon } = weather[0]; + const { + main: { aqi }, + } = list[0]; + + return { + dt: dt, + name: name, + country: country, + description: description, + icon: icon, + temp: typeof temp === 'number' ? temp : temp.max, + wind_speed: wind_speed, + wind_deg: wind_deg, + aqi: aqi, + humidity: humidity, + }; +}; diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx new file mode 100644 index 0000000..7d6f3ef --- /dev/null +++ b/src/pages/Home/Home.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import Input from '../../modules/@shared/components/Input'; +import WeatherCard from '../../modules/weather/components/WeatherCard'; +import WeatherNotFound from '../../modules/weather/components/WeatherNotFound'; +import { useMeasurementUnitDispatch } from '../../modules/weather/contexts/MeasurementUnitContext'; +import { useWeatherService } from '../../modules/weather/services/useWeatherService'; + +type Props = {}; + +const Home = (props: Props) => { + const [searchParams, setSearchParams] = useSearchParams(); + const cityParams = searchParams.get('city') || ''; + + const [count, setCount] = useState(cityParams); + + const { data, isLoading, error, refetch } = useWeatherService({ + city: cityParams, + }); + const handleEnter = () => { + setSearchParams({ city: count }); + }; + useEffect(() => { + console.log({ data }); + }, [data]); + + return ( + <div> + <div className="max-w-3xl mx-auto mt-20"> + <div className="mb-3"> + <Input + placeholder="City name" + value={count} + onChange={(value) => setCount(value)} + onEnterPress={handleEnter} + clearable + /> + </div> + {!!data ? ( + <WeatherCard + geocoding={data.geocoding} + weatherCurrent={data.weatherOneCall} + airPollution={data.airPollution} + /> + ) : ( + <WeatherNotFound /> + )} + </div> + </div> + ); +}; + +export default Home; diff --git a/src/pages/Home/index.ts b/src/pages/Home/index.ts new file mode 100644 index 0000000..41e08ee --- /dev/null +++ b/src/pages/Home/index.ts @@ -0,0 +1 @@ +export { default } from './Home'; diff --git a/tsconfig.json b/tsconfig.json index c8bdc64..f354d51 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,8 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "types": ["@honkhonk/vite-plugin-svgr/client"] }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/vite.config.ts b/vite.config.ts index 7ab756b..1775415 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,12 @@ -import { defineConfig } from 'vite'; +import svgr from '@honkhonk/vite-plugin-svgr'; import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; // https://vitejs.dev/config/ /** * @type {import('vite').UserConfig} */ export default defineConfig({ - plugins: [react()], - server: { port: 3333 }, + plugins: [react(), svgr()], + server: { port: 3333, cors: false }, });