├── sandbox.config.json ├── public └── assets │ ├── worldmap.jpg │ ├── arrow-left.svg │ ├── check.svg │ ├── home-outline.svg │ ├── starbox.svg │ ├── star-with-stroke.svg │ ├── star.svg │ ├── earth.svg │ └── map.svg ├── components ├── StyledHeadline.js ├── Form │ ├── StyledForm.js │ ├── StyledFieldset.js │ └── index.js ├── Header │ ├── index.js │ ├── Header.test.js │ └── StyledHeader.js ├── CounterContinent │ ├── StyledCounterContinent.js │ ├── CounterContinent.test.js │ └── index.js ├── SearchBar │ ├── StyledSearchBar.js │ ├── index.js │ └── SearchBar.test.js ├── StyledFlag.js ├── FilterContinents │ ├── StyledFilterContinents.js │ ├── FilterContinents.test.js │ └── index.js ├── StyledFooter.js ├── StyledSVG.js ├── Layout │ └── index.js ├── StyledButton.js ├── StyledList.js ├── StyledProgressBar.js ├── CountriesPreview │ ├── index.js │ └── CountriesPreview.test.js ├── FavoriteButton │ └── index.js ├── StyledListElement.js ├── VisitedButton │ └── index.js ├── Counter │ ├── index.js │ └── Counter.test.js ├── StyledDiv.js ├── Navigation │ └── index.js ├── CountriesList │ └── index.js ├── CountryDetails │ └── index.js └── Entry │ └── index.js ├── jest.setup.js ├── .eslintrc.json ├── pages ├── countries │ └── [name].js ├── _document.js ├── index.js ├── list.js ├── visited.js ├── _app.js └── favorites.js ├── .gitignore ├── next.config.js ├── .prettierrc.json ├── styles.js ├── jest.config.js ├── package.json └── README.md /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { "template": "next" } 2 | -------------------------------------------------------------------------------- /public/assets/worldmap.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnjaSiewert/capstone_globehopper/HEAD/public/assets/worldmap.jpg -------------------------------------------------------------------------------- /components/StyledHeadline.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const StyledHeadline = styled.h2` 4 | font-size: 1.8rem; 5 | `; 6 | 7 | export default StyledHeadline; 8 | -------------------------------------------------------------------------------- /public/assets/arrow-left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/Form/StyledForm.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const StyledForm = styled.form` 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | `; 8 | 9 | export default StyledForm; 10 | -------------------------------------------------------------------------------- /components/Header/index.js: -------------------------------------------------------------------------------- 1 | import StyledHeader from "./StyledHeader"; 2 | 3 | export default function Header({ headline }) { 4 | return ( 5 | 6 |

{headline}

7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /components/CounterContinent/StyledCounterContinent.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const StyledCounterContinent = styled.div` 4 | font-size: 1rem; 5 | border-radius: 0.5rem; 6 | `; 7 | 8 | export default StyledCounterContinent; 9 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | // Optional: configure or set up a testing framework before each test. 2 | // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js` 3 | 4 | // Learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom/extend-expect"; 6 | -------------------------------------------------------------------------------- /public/assets/check.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/home-outline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/SearchBar/StyledSearchBar.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const StyledSeachbar = styled.input` 4 | margin-top: 2rem; 5 | border: 1px solid var(--antracite); 6 | border-radius: 5px; 7 | width: 10rem; 8 | `; 9 | 10 | export default StyledSeachbar; 11 | -------------------------------------------------------------------------------- /components/StyledFlag.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | 3 | const StyledFlag = styled.aside` 4 | font-size: 3.5rem; 5 | ${(props) => 6 | props.isOnDetailsPage && 7 | css` 8 | font-size: 5rem; 9 | `} 10 | `; 11 | 12 | export default StyledFlag; 13 | -------------------------------------------------------------------------------- /components/Header/Header.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import Header from "."; 3 | 4 | test("renders Header component", () => { 5 | render(
); 6 | const headerElement = screen.getByRole("heading"); 7 | expect(headerElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /public/assets/starbox.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/FilterContinents/StyledFilterContinents.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const StyledFilterContinents = styled.select` 4 | margin-top: 2rem; 5 | border: 1px solid var(--antracite); 6 | border-radius: 5px; 7 | width: 10rem; 8 | `; 9 | 10 | export default StyledFilterContinents; 11 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | }, 5 | "extends": ["next/core-web-vitals", "plugin:jest/recommended"], 6 | "rules": { 7 | "import/no-anonymous-default-export": [ 8 | "error", 9 | { 10 | "allowObject": true 11 | } 12 | ] 13 | }, 14 | "plugins": ["jest"] 15 | } 16 | -------------------------------------------------------------------------------- /components/Header/StyledHeader.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const StyledHeader = styled.header` 4 | display: flex; 5 | justify-content: center; 6 | background-color: var(--tourquoise); 7 | position: fixed; 8 | top: 0; 9 | width: 100%; 10 | z-index: 1; 11 | `; 12 | 13 | export default StyledHeader; 14 | -------------------------------------------------------------------------------- /components/Form/StyledFieldset.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | 3 | const StyledFieldset = styled.fieldset` 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | ${(props) => 8 | props.isCheckbox && 9 | css` 10 | gap: 0.5rem; 11 | `} 12 | `; 13 | 14 | export default StyledFieldset; 15 | -------------------------------------------------------------------------------- /components/StyledFooter.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const StyledFooter = styled.footer` 4 | display: flex; 5 | justify-content: space-around; 6 | align-items: center; 7 | position: fixed; 8 | bottom: 0; 9 | width: 100%; 10 | height: 4rem; 11 | background-color: var(--tourquoise); 12 | `; 13 | 14 | export default StyledFooter; 15 | -------------------------------------------------------------------------------- /public/assets/star-with-stroke.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/StyledSVG.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | 3 | const StyledSVG = styled.div` 4 | ${(props) => 5 | props.positionSVG && 6 | css` 7 | display: flex; 8 | justify-content: center; 9 | margin-bottom: 0.3rem; 10 | `} 11 | background: transparent; 12 | border: none; 13 | `; 14 | 15 | export default StyledSVG; 16 | -------------------------------------------------------------------------------- /pages/countries/[name].js: -------------------------------------------------------------------------------- 1 | import Header from "../../components/Header"; 2 | import CountryDetails from "../../components/CountryDetails"; 3 | 4 | export default function CountryDetailsPage({ selectedCountry, name }) { 5 | return ( 6 | <> 7 |
8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /public/assets/star.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/Layout/index.js: -------------------------------------------------------------------------------- 1 | import Navigation from "../Navigation"; 2 | import styled from "styled-components"; 3 | 4 | const StyledMain = styled.main` 5 | margin-bottom: 5rem; 6 | `; 7 | 8 | export default function Layout({ children }) { 9 | return ( 10 | <> 11 | {children} 12 |
13 | 14 |
15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /public/assets/earth.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/StyledButton.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | 3 | const StyledButton = styled.button` 4 | border-radius: 0.5rem; 5 | font-size: 1rem; 6 | width: 8rem; 7 | background-color: #a8c3c5; 8 | color: var(--anthracite); 9 | margin-bottom: 0.8rem; 10 | 11 | ${(props) => 12 | props.isHidingForm && 13 | css` 14 | align-self: center; 15 | `} 16 | ${(props) => props.isSubmit && css``} 17 | 18 | &:hover { 19 | transform: scale(1.02); 20 | } 21 | `; 22 | 23 | export default StyledButton; 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | compiler: { 4 | styledComponents: true, 5 | }, 6 | i18n: { 7 | locales: ["en"], 8 | defaultLocale: "en", 9 | }, 10 | reactStrictMode: true, 11 | images: { 12 | domains: ["images.unsplash.com"], 13 | }, 14 | webpack(config) { 15 | config.module.rules.push({ 16 | test: /\.svg$/i, 17 | issuer: /\.[jt]sx?$/, 18 | use: ["@svgr/webpack"], 19 | }); 20 | 21 | return config; 22 | }, 23 | }; 24 | 25 | module.exports = nextConfig; 26 | -------------------------------------------------------------------------------- /public/assets/map.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/StyledList.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | 3 | const StyledList = styled.ul` 4 | display: flex; 5 | flex-direction: column; 6 | align-items: space-between; 7 | padding-left: 2rem; 8 | padding-right: 2rem; 9 | ${(props) => 10 | props.isOnCard && 11 | css` 12 | margin-top: 7rem; 13 | `} 14 | ${(props) => 15 | props.isOnEntryList && 16 | css` 17 | gap: 0.5rem; 18 | margin: 0.5rem; 19 | align-items: center; 20 | padding: 1rem; 21 | `} 22 | `; 23 | 24 | export default StyledList; 25 | -------------------------------------------------------------------------------- /components/StyledProgressBar.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const StyledProgressBar = styled.div` 4 | margin-top: 1rem; 5 | border: 1px solid black; 6 | border-radius: 0.5rem; 7 | height: 1rem; 8 | width: 6rem; 9 | background-color: white; 10 | 11 | &::before { 12 | content: ""; 13 | display: block; 14 | height: 100%; 15 | width: 100%; 16 | background-color: var(--tourquoise); 17 | border-radius: inherit; 18 | padding-left: 15px; 19 | width: ${(props) => `${props.percent}%`}; 20 | } 21 | `; 22 | 23 | export default StyledProgressBar; 24 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "quoteProps": "as-needed", 8 | "jsxSingleQuote": false, 9 | "trailingComma": "es5", 10 | "bracketSpacing": true, 11 | "bracketSameLine": false, 12 | "jsxBracketSameLine": false, 13 | "arrowParens": "always", 14 | "requirePragma": false, 15 | "insertPragma": false, 16 | "proseWrap": "preserve", 17 | "htmlWhitespaceSensitivity": "css", 18 | "vueIndentScriptAndStyle": false, 19 | "endOfLine": "lf", 20 | "embeddedLanguageFormatting": "auto", 21 | "singleAttributePerLine": false 22 | } 23 | -------------------------------------------------------------------------------- /components/CountriesPreview/index.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import StyledFlag from "../StyledFlag"; 3 | 4 | const StyledCard = styled.div` 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | `; 9 | 10 | export default function CountriesPreview({ name, capital, continent, flag }) { 11 | return ( 12 | 13 |

{name}

14 | 15 | Capital: 16 | {capital} 17 | 18 | 19 | Continent: 20 | {continent} 21 | 22 | {flag} 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/FavoriteButton/index.js: -------------------------------------------------------------------------------- 1 | import Star from "../../public/assets/star-with-stroke.svg"; 2 | import StyledSVG from "../StyledSVG"; 3 | 4 | const StarLiked = () => ; 5 | 6 | export default function FavoriteButton({ 7 | onToggleFavorite, 8 | name, 9 | countriesInfo, 10 | }) { 11 | const { isFavorite } = countriesInfo.find( 12 | (country) => country.name === name 13 | ) ?? { isFavorite: false }; 14 | 15 | return ( 16 | <> 17 | { 20 | onToggleFavorite(name); 21 | }} 22 | > 23 | {isFavorite ? : } 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /components/StyledListElement.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | 3 | const StyledListElement = styled.li` 4 | ${(props) => 5 | props.isOnListpage && 6 | css` 7 | display: flex; 8 | justify-content: space-between; 9 | align-items: center; 10 | padding: 0.3rem; 11 | background-color: var(--lightgrey); 12 | border-radius: 01rem; 13 | `} 14 | ${(props) => 15 | props.isOnFavoritesPage && 16 | css` 17 | display: flex; 18 | flex-direction: column; 19 | border-radius: 1rem; 20 | background-color: #f0ebed; 21 | `} 22 | 23 | list-style: none; 24 | margin: 0.3rem; 25 | border: 1px solid grey; 26 | color: var(--antracite); 27 | `; 28 | 29 | export default StyledListElement; 30 | -------------------------------------------------------------------------------- /components/VisitedButton/index.js: -------------------------------------------------------------------------------- 1 | import Check from "../../public/assets/check.svg"; 2 | import StyledSVG from "../StyledSVG"; 3 | 4 | const CheckLiked = () => ; 5 | 6 | export default function VisitedButton({ 7 | onToggleVisited, 8 | name, 9 | countriesInfo, 10 | }) { 11 | const { isVisited } = countriesInfo.find( 12 | (country) => country.name === name 13 | ) ?? { isVisited: false }; 14 | 15 | return ( 16 | <> 17 | { 20 | onToggleVisited(name); 21 | }} 22 | > 23 | {isVisited ? : } 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /components/Counter/index.js: -------------------------------------------------------------------------------- 1 | import StyledDiv from "../StyledDiv"; 2 | import StyledHeadline from "../StyledHeadline"; 3 | import StyledProgressBar from "../StyledProgressBar"; 4 | 5 | export default function Counter({ countries, countriesInfo }) { 6 | const countVisitedCountries = countriesInfo.filter((info) => info.isVisited); 7 | const percentVisited = 8 | (countVisitedCountries.length / countries.length) * 100; 9 | 10 | return ( 11 | <> 12 | 13 | The World 14 | {countVisitedCountries.length} /{countries.length} 15 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document from "next/document"; 2 | import { ServerStyleSheet } from "styled-components"; 3 | 4 | export default class MyDocument extends Document { 5 | static async getInitialProps(ctx) { 6 | const sheet = new ServerStyleSheet(); 7 | const originalRenderPage = ctx.renderPage; 8 | 9 | try { 10 | ctx.renderPage = () => 11 | originalRenderPage({ 12 | enhanceApp: (App) => (props) => 13 | sheet.collectStyles(), 14 | }); 15 | 16 | const initialProps = await Document.getInitialProps(ctx); 17 | return { 18 | ...initialProps, 19 | styles: [initialProps.styles, sheet.getStyleElement()], 20 | }; 21 | } finally { 22 | sheet.seal(); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /styles.js: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from "styled-components"; 2 | 3 | export default createGlobalStyle` 4 | *, 5 | *::before, 6 | *::after { 7 | box-sizing: border-box; 8 | } 9 | 10 | :root { 11 | --tourquoise: #9ac6bc; 12 | --lightgrey: #D4CFD1; 13 | --antracite: #3d3d3d; 14 | --beige: #F2F2F2; 15 | } 16 | 17 | body { 18 | margin: 0; 19 | font-family: system-ui; 20 | color: var(--antracite); 21 | background-color: var(--beige); 22 | } 23 | 24 | fieldset { 25 | border: none; 26 | } 27 | 28 | legend { 29 | text-align: center; 30 | } 31 | 32 | li { 33 | list-style: none; 34 | } 35 | 36 | i { 37 | text-align: center; 38 | } 39 | 40 | Image { 41 | object-fit: cover; 42 | } 43 | 44 | 45 | `; 46 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require("next/jest"); 2 | 3 | const createJestConfig = nextJest({ 4 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 5 | dir: "./", 6 | }); 7 | 8 | // Add any custom config to be passed to Jest 9 | /** @type {import('jest').Config} */ 10 | const customJestConfig = { 11 | // Add more setup options before each test is run 12 | setupFilesAfterEnv: ["/jest.setup.js"], 13 | // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work 14 | moduleDirectories: ["node_modules", "/"], 15 | testEnvironment: "jest-environment-jsdom", 16 | }; 17 | 18 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 19 | module.exports = createJestConfig(customJestConfig); 20 | -------------------------------------------------------------------------------- /components/SearchBar/index.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import StyledSeachbar from "./StyledSearchBar"; 3 | 4 | export default function Searchbar({ countries, setFilteredCountries }) { 5 | const [searchInput, setSearchInput] = useState(""); 6 | 7 | const handleChange = (event) => { 8 | event.preventDefault(); 9 | setSearchInput(event.target.value); 10 | setFilteredCountries( 11 | countries.filter((country) => { 12 | return country.name.common 13 | .toLowerCase() 14 | .includes(event.target.value.toLowerCase()); 15 | }) 16 | ); 17 | }; 18 | 19 | return ( 20 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Header from "../components/Header"; 2 | import Image from "next/image"; 3 | import Counter from "../components/Counter"; 4 | import StyledDiv from "../components/StyledDiv"; 5 | import worldMap from "../public/assets/worldmap.jpg"; 6 | import CounterContinent from "../components/CounterContinent"; 7 | 8 | export default function HomePage({ countries, countriesInfo }) { 9 | return ( 10 | <> 11 |
12 | 13 | worldmap 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/CountriesPreview/CountriesPreview.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import CountriesPreview from "../CountriesPreview"; 3 | 4 | test("renders country information correctly", async () => { 5 | const name = "Canada"; 6 | const capital = "Ottawa"; 7 | const continent = "North America"; 8 | const flag = "🇨🇦"; 9 | 10 | render( 11 | 17 | ); 18 | 19 | const countryName = await screen.findByText(name); 20 | const countryCapital = await screen.findByText(capital); 21 | const countryContinent = await screen.findByText(continent); 22 | const countryFlag = await screen.findByText(flag); 23 | 24 | expect(countryName).toBeInTheDocument(); 25 | expect(countryCapital).toBeInTheDocument(); 26 | expect(countryContinent).toBeInTheDocument(); 27 | expect(countryFlag).toBeInTheDocument(); 28 | }); 29 | -------------------------------------------------------------------------------- /components/SearchBar/SearchBar.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import Searchbar from "./index"; 4 | 5 | test("renders search input", () => { 6 | const countries = []; 7 | const setFilteredCountries = jest.fn(); 8 | render( 9 | 13 | ); 14 | const searchInput = screen.getByPlaceholderText(/Search for a country.../i); 15 | expect(searchInput).toBeInTheDocument(); 16 | }); 17 | 18 | test("updates searchInput state when user types into input", async () => { 19 | const countries = []; 20 | const setFilteredCountries = jest.fn(); 21 | render( 22 | 26 | ); 27 | const searchInput = screen.getByPlaceholderText(/Search for a country.../i); 28 | await userEvent.type(searchInput, "Canada"); 29 | expect(searchInput).toHaveValue("Canada"); 30 | }); 31 | -------------------------------------------------------------------------------- /components/StyledDiv.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | 3 | const StyledDiv = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | text-align: center; 9 | 10 | ${(props) => 11 | props.withBorder && 12 | css` 13 | border: 1px solid grey; 14 | border-radius: 1rem; 15 | padding: 0rem 0.5rem 1rem 0.5rem; 16 | background-color: var(--lightgrey); 17 | margin: 1.5rem 4.5rem 1.5rem 4.5rem; 18 | `} 19 | ${(props) => 20 | props.withBorderTotal && 21 | css` 22 | border: 1px solid grey; 23 | border-radius: 1rem; 24 | padding-bottom: 1rem; 25 | background-color: var(--lightgrey); 26 | margin: 1.5rem 3.5rem 1.5rem 3.5rem; 27 | font-size: 1.3rem; 28 | `} 29 | ${(props) => 30 | props.isOnDetailsPage && 31 | css` 32 | margin: 6rem 1rem 0rem 1rem; 33 | gap: 1rem; 34 | `} 35 | ${(props) => 36 | props.isShowingImage && 37 | css` 38 | margin-top: 7rem; 39 | `} 40 | `; 41 | 42 | export default StyledDiv; 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "globehopper", 3 | "version": "0.0.0-unreleased", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "jest --watchAll" 11 | }, 12 | "dependencies": { 13 | "@next/font": "^13.0.6", 14 | "eslint": "8.29.0", 15 | "eslint-config-next": "13.0.6", 16 | "immer": "^9.0.21", 17 | "nanoid": "^4.0.2", 18 | "next": "13.0.6", 19 | "react": "18.2.0", 20 | "react-dom": "18.2.0", 21 | "styled-components": "^5.3.6", 22 | "swr": "^2.1.2", 23 | "use-immer": "^0.9.0", 24 | "use-local-storage-state": "^18.3.0" 25 | }, 26 | "devDependencies": { 27 | "@svgr/webpack": "^6.5.1", 28 | "@testing-library/jest-dom": "^5.16.5", 29 | "@testing-library/react": "^14.0.0", 30 | "@testing-library/user-event": "^14.4.3", 31 | "eslint-plugin-jest": "^27.1.6", 32 | "jest": "^29.3.1", 33 | "jest-environment-jsdom": "^29.3.1" 34 | }, 35 | "description": "Next.js (incl. Styled Components and Jest)", 36 | "nf": { 37 | "template": "next" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pages/list.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import CountriesList from "../components/CountriesList"; 3 | import Header from "../components/Header"; 4 | import Searchbar from "../components/SearchBar"; 5 | import FilterContinents from "../components/FilterContinents"; 6 | import styled from "styled-components"; 7 | 8 | const StyledSearchbarContainer = styled.div` 9 | display: flex; 10 | justify-content: space-evenly; 11 | align-items: center; 12 | margin-top: 4rem; 13 | `; 14 | 15 | export default function CountriesListPage({ 16 | countries, 17 | onToggleVisited, 18 | onToggleFavorite, 19 | countriesInfo, 20 | }) { 21 | const [filteredCountries, setFilteredCountries] = useState(countries); 22 | return ( 23 | <> 24 |
25 | 26 | 30 | 34 | 35 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /components/Navigation/index.js: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Home from "../../public/assets/home-outline.svg"; 3 | import Earth from "../../public/assets/earth.svg"; 4 | import Map from "../../public/assets/map.svg"; 5 | import StyledFooter from "../StyledFooter"; 6 | import Star from "../../public/assets/star.svg"; 7 | import { useRouter } from "next/router"; 8 | 9 | export default function Navigation() { 10 | const router = useRouter(); 11 | const activeStyles = { width: "60px", height: "60px" }; 12 | const inactiveStyles = { width: "35px", height: "35px" }; 13 | 14 | function isActive(href) { 15 | return router.pathname === href; 16 | } 17 | 18 | return ( 19 | 20 | 21 | 25 | 26 | 27 | 31 | 32 | 33 | 37 | 38 | 39 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /components/Counter/Counter.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import Counter from "."; 3 | 4 | test("renders a headline", () => { 5 | const countries = [{ name: "Canada" }]; 6 | const countriesInfo = [{ name: "Canada", isVisited: true }]; 7 | render(); 8 | const heading = screen.getByRole("heading", { name: /the world/i }); 9 | expect(heading).toBeInTheDocument(); 10 | }); 11 | 12 | test("displays the correct number of visited countries", () => { 13 | const countries = [ 14 | { name: "Canada" }, 15 | { name: "United States" }, 16 | { name: "Mexico" }, 17 | ]; 18 | const countriesInfo = [ 19 | { name: "Canada", isVisited: true }, 20 | { name: "United States", isVisited: false }, 21 | { name: "Mexico", isVisited: true }, 22 | ]; 23 | 24 | render(); 25 | 26 | const visitedCountries = screen.getByText("2 /3"); 27 | expect(visitedCountries).toBeInTheDocument(); 28 | }); 29 | 30 | test("displays the correct percentage of visited countries", () => { 31 | const countries = [ 32 | { name: "Canada" }, 33 | { name: "United States" }, 34 | { name: "Mexico" }, 35 | ]; 36 | const countriesInfo = [ 37 | { name: "Canada", isVisited: true }, 38 | { name: "United States", isVisited: false }, 39 | { name: "Mexico", isVisited: true }, 40 | ]; 41 | 42 | render(); 43 | 44 | const progressBar = screen.getByTestId("progress-bar"); 45 | expect(progressBar).toHaveStyle({ width: "6rem" }); 46 | }); 47 | -------------------------------------------------------------------------------- /components/CountriesList/index.js: -------------------------------------------------------------------------------- 1 | import VisitedButton from "../VisitedButton"; 2 | import StyledListElement from "../StyledListElement"; 3 | import StyledList from "../StyledList"; 4 | import FavoriteButton from "../FavoriteButton"; 5 | import styled from "styled-components"; 6 | import Link from "next/link"; 7 | 8 | const StyledVisitedToggle = styled.div` 9 | position: absolute; 10 | right: 5rem; 11 | `; 12 | 13 | const StyledLink = styled(Link)` 14 | width: 13rem; 15 | padding-left: 0.3rem; 16 | text-decoration: none; 17 | color: var(----antracite); 18 | `; 19 | 20 | export default function CountriesList({ 21 | countries, 22 | onToggleVisited, 23 | onToggleFavorite, 24 | countriesInfo, 25 | }) { 26 | return ( 27 | <> 28 | 29 | Click on a countries name for details 30 | {countries 31 | .map((country) => ({ 32 | key: country.name, 33 | name: country.name.common, 34 | })) 35 | .sort((a, b) => a.name.localeCompare(b.name)) 36 | .map((country) => ( 37 | 38 | 39 | {country.name}{" "} 40 | 41 | 42 | 47 | 48 | 53 | 54 | ))} 55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /components/FilterContinents/FilterContinents.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import FilterContinents from "./index"; 4 | 5 | test("filters countries by continent when user selects an option", async () => { 6 | const countries = [ 7 | { 8 | name: { 9 | common: "Canada", 10 | }, 11 | region: "Americas", 12 | subregion: "North America", 13 | continents: ["North America"], 14 | }, 15 | { 16 | name: { 17 | common: "Mexico", 18 | }, 19 | region: "Americas", 20 | subregion: "North America", 21 | continents: ["North America"], 22 | }, 23 | { 24 | name: { 25 | common: "Japan", 26 | }, 27 | region: "Asia", 28 | subregion: "Eastern Asia", 29 | continents: ["Asia"], 30 | }, 31 | { 32 | name: { 33 | common: "Australia", 34 | }, 35 | region: "Oceania", 36 | subregion: "Australia and New Zealand", 37 | continents: ["Oceania"], 38 | }, 39 | ]; 40 | const setFilteredCountries = jest.fn(); 41 | 42 | render( 43 | 47 | ); 48 | 49 | const selectElement = screen.getByRole("combobox"); 50 | await userEvent.selectOptions(selectElement, "North America"); 51 | 52 | expect(setFilteredCountries).toHaveBeenCalledTimes(1); 53 | expect(setFilteredCountries).toHaveBeenCalledWith([ 54 | { 55 | name: { 56 | common: "Canada", 57 | }, 58 | region: "Americas", 59 | subregion: "North America", 60 | continents: ["North America"], 61 | }, 62 | { 63 | name: { 64 | common: "Mexico", 65 | }, 66 | region: "Americas", 67 | subregion: "North America", 68 | continents: ["North America"], 69 | }, 70 | ]); 71 | }); 72 | -------------------------------------------------------------------------------- /pages/visited.js: -------------------------------------------------------------------------------- 1 | import CountriesPreview from "../components/CountriesPreview"; 2 | import Header from "../components/Header"; 3 | import StyledList from "../components/StyledList"; 4 | import StyledListElement from "../components/StyledListElement"; 5 | import VisitedButton from "../components/VisitedButton"; 6 | import StyledSVG from "../components/StyledSVG"; 7 | import { Fragment } from "react"; 8 | 9 | export default function VisitedCountriesPage({ 10 | countries, 11 | countriesInfo, 12 | onToggleVisited, 13 | }) { 14 | const listVisitedCountries = countriesInfo.filter((info) => info.isVisited); 15 | 16 | const visitedCountries = countries.filter((country) => 17 | listVisitedCountries.find((info) => info.name === country.name.common) 18 | ); 19 | 20 | return ( 21 | <> 22 |
23 | 24 | {visitedCountries 25 | .slice() 26 | .sort((a, b) => a.name.common.localeCompare(b.name.common)) 27 | .map((country) => { 28 | return ( 29 | 30 | 31 | 40 | 41 | 46 | 47 | 48 | 49 | ); 50 | })} 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /components/CounterContinent/CounterContinent.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import CounterContinent from "."; 3 | 4 | test("renders a headline", () => { 5 | const countries = [{ name: "Canada" }]; 6 | const countriesInfo = [{ name: "Canada", isVisited: true }]; 7 | render( 8 | 9 | ); 10 | const heading = screen.getByRole("heading", { name: /north america/i }); 11 | expect(heading).toBeInTheDocument(); 12 | }); 13 | 14 | test("displays 1 progress bar per continent", () => { 15 | const countries = [ 16 | { name: "Canada" }, 17 | { name: "United States" }, 18 | { name: "Mexico" }, 19 | ]; 20 | const countriesInfo = [ 21 | { name: "Canada", isVisited: true }, 22 | { name: "United States", isVisited: false }, 23 | { name: "Mexico", isVisited: true }, 24 | ]; 25 | 26 | render( 27 | 28 | ); 29 | 30 | const progressBar = screen.getAllByTestId("progress-bar-continent"); 31 | expect(progressBar).toHaveLength(7); 32 | }); 33 | 34 | test("displays the correct number of total countries per continent", async () => { 35 | const countries = [ 36 | { name: "Germany", region: "Europe" }, 37 | { name: "France", region: "Europe" }, 38 | { name: "Spain", region: "Europe" }, 39 | { name: "Italy", region: "Europe" }, 40 | { name: "United Kingdom", region: "Europe" }, 41 | { name: "Poland", region: "Europe" }, 42 | ]; 43 | 44 | const countriesInfo = [ 45 | { name: "Germany", isVisited: true }, 46 | { name: "France", isVisited: true }, 47 | { name: "Spain", isVisited: true }, 48 | { name: "Italy", isVisited: true }, 49 | { name: "United Kingdom", isVisited: true }, 50 | { name: "Poland", isVisited: true }, 51 | ]; 52 | 53 | render( 54 | 55 | ); 56 | 57 | const totalCountriesEurope = screen.getByText("0 / 6"); 58 | expect(totalCountriesEurope).toBeInTheDocument(); 59 | }); 60 | -------------------------------------------------------------------------------- /components/FilterContinents/index.js: -------------------------------------------------------------------------------- 1 | import StyledFilterContinents from "./StyledFilterContinents"; 2 | 3 | export default function FilterContinents({ countries, setFilteredCountries }) { 4 | const handleSelect = (event) => { 5 | if (event.target.value === "all") { 6 | setFilteredCountries(countries); 7 | } else if (event.target.value === "africa") { 8 | setFilteredCountries( 9 | countries.filter((country) => country.region === "Africa") 10 | ); 11 | } else if (event.target.value === "antarctica") { 12 | setFilteredCountries( 13 | countries.filter((country) => country.region === "Antarctic") 14 | ); 15 | } else if (event.target.value === "asia") { 16 | setFilteredCountries( 17 | countries.filter((country) => country.region === "Asia") 18 | ); 19 | } else if (event.target.value === "europe") { 20 | setFilteredCountries( 21 | countries.filter((country) => country.region === "Europe") 22 | ); 23 | } else if (event.target.value === "north america") { 24 | setFilteredCountries( 25 | countries.filter((country) => { 26 | return ( 27 | country.continents.includes("North America") && 28 | country.subregion !== "Central America" 29 | ); 30 | }) 31 | ); 32 | } else if (event.target.value === "oceania") { 33 | setFilteredCountries( 34 | countries.filter((country) => { 35 | return country.region === "Oceania"; 36 | }) 37 | ); 38 | } else if (event.target.value === "south america") { 39 | setFilteredCountries( 40 | countries.filter((country) => 41 | country.continents.includes("South America") 42 | ) 43 | ); 44 | } else if (event.target.value === "central america") { 45 | setFilteredCountries( 46 | countries.filter((country) => country.subregion === "Central America") 47 | ); 48 | } 49 | }; 50 | 51 | return ( 52 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import GlobalStyle from "../styles"; 2 | import useSWR from "swr"; 3 | import Layout from "../components/Layout"; 4 | import useLocalStorageState from "use-local-storage-state"; 5 | import { useRouter } from "next/router"; 6 | import Head from "next/head"; 7 | 8 | const fetcher = (...args) => fetch(...args).then((res) => res.json()); 9 | 10 | export default function App({ Component, pageProps }) { 11 | const [countriesInfo, setCountriesInfo] = useLocalStorageState( 12 | "countriesInfo", 13 | { defaultValue: [] } 14 | ); 15 | 16 | const router = useRouter(); 17 | const { name } = router.query; 18 | 19 | const { data, error, isLoading } = useSWR( 20 | name 21 | ? `https://restcountries.com/v3.1/name/${name}` 22 | : "https://restcountries.com/v3.1/all", 23 | fetcher 24 | ); 25 | 26 | if (error) return

An error has occurred.

; 27 | if (isLoading) return

is loading...

; 28 | 29 | const selectedCountry = data.find( 30 | (country) => country.name.common.toLowerCase() === name 31 | ); 32 | 33 | function handleToggleVisited(name) { 34 | setCountriesInfo((countriesInfo) => { 35 | const info = countriesInfo.find( 36 | (countryInfo) => countryInfo.name === name 37 | ); 38 | if (info) { 39 | return countriesInfo.map((countryInfo) => 40 | countryInfo.name === name 41 | ? { ...countryInfo, isVisited: !countryInfo.isVisited } 42 | : countryInfo 43 | ); 44 | } 45 | return [...countriesInfo, { name, isVisited: true }]; 46 | }); 47 | } 48 | 49 | function handleToggleFavorite(name) { 50 | setCountriesInfo((countriesInfo) => { 51 | const favoriteInfo = countriesInfo.find( 52 | (countryInfo) => countryInfo.name === name 53 | ); 54 | 55 | if (favoriteInfo) { 56 | return countriesInfo.map((countryInfo) => 57 | countryInfo.name === name 58 | ? { 59 | ...countryInfo, 60 | isFavorite: !countryInfo.isFavorite, 61 | } 62 | : countryInfo 63 | ); 64 | } 65 | return [...countriesInfo, { name, isFavorite: true }]; 66 | }); 67 | } 68 | 69 | return ( 70 | <> 71 | 72 | globehopper 73 | 74 | 75 | 76 | 77 | 85 | 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /components/CounterContinent/index.js: -------------------------------------------------------------------------------- 1 | import StyledDiv from "../StyledDiv"; 2 | import StyledProgressBar from "../StyledProgressBar"; 3 | import StyledCounterContinent from "./StyledCounterContinent"; 4 | 5 | export default function CounterContinent({ countries, countriesInfo }) { 6 | const allContinents = [ 7 | { 8 | name: "Europe", 9 | countries: countries.filter((country) => country.region === "Europe"), 10 | }, 11 | { 12 | name: "Asia", 13 | countries: countries.filter((country) => country.region === "Asia"), 14 | }, 15 | { 16 | name: "Africa", 17 | countries: countries.filter((country) => country.region === "Africa"), 18 | }, 19 | { 20 | name: "North America", 21 | countries: countries.filter( 22 | (country) => 23 | country.region === "Americas" && country.subregion === "North America" 24 | ), 25 | }, 26 | { 27 | name: "South America", 28 | countries: countries.filter( 29 | (country) => 30 | country.region === "Americas" && country.subregion == "South America" 31 | ), 32 | }, 33 | { 34 | name: "Oceania", 35 | countries: countries.filter((country) => country.region === "Oceania"), 36 | }, 37 | { 38 | name: "Antarctica", 39 | countries: countries.filter((country) => country.region === "Antarctic"), 40 | }, 41 | ]; 42 | 43 | const continentCounts = allContinents.map((continent) => { 44 | const visitedCountries = continent.countries.filter((country) => 45 | countriesInfo 46 | .filter((info) => info.isVisited) 47 | .find((info) => info.name === country.name.common) 48 | ); 49 | 50 | return { 51 | name: continent.name, 52 | visitedCount: visitedCountries.length, 53 | totalCount: continent.countries.length, 54 | percent: ( 55 | (visitedCountries.length / continent.countries.length) * 56 | 100 57 | ).toFixed(2), 58 | }; 59 | }); 60 | 61 | return ( 62 | <> 63 | {continentCounts 64 | .map((continent) => ({ 65 | ...continent, 66 | key: continent.name, 67 | })) 68 | .sort((a, b) => a.name.localeCompare(b.name)) 69 | .map((continent) => ( 70 | 71 |

{continent.name}

72 |
73 | 74 | {continent.visitedCount} / {continent.totalCount}{" "} 75 | 79 | 80 |
81 |
82 | ))} 83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Globehopper App - Capstone Project at neuefische Bootcamp 2 | 3 | Globehopper is an app, where you can track which countries you have already visited and where you want to go next 4 | 5 | ## Description 6 | 7 | With this travel app, you can easily keep track of the countries you have visited and plan your future travels all in one place 8 | 9 | ![1](https://user-images.githubusercontent.com/123647583/234690102-e5ecbc34-045f-490a-9987-ef0715e6217a.png) 10 | ![2](https://user-images.githubusercontent.com/123647583/234688607-5770e5fa-36ac-4adc-8796-62623543d35c.png) 11 | ![3](https://user-images.githubusercontent.com/123647583/234689534-2d16f01c-ad6d-4b32-913b-bf359c7e281c.png) 12 | 13 | ![4](https://user-images.githubusercontent.com/123647583/234688115-ef66ce7a-5f5b-4554-b74a-da15d8c90431.png) 14 | ![5](https://user-images.githubusercontent.com/123647583/234689774-128043eb-c319-4af9-96c0-4ad3d718a7df.png) 15 | ![6](https://user-images.githubusercontent.com/123647583/234689211-bdb53ea5-797e-443f-9f1d-c4a7f73647a6.png) 16 | 17 | 18 | ## Getting started 19 | 20 | To get started simply visit [Glopehopper App](https://capstone-globehopper.vercel.app/) and try it out! No further installation is required. 21 | Just open the link on your preferred web browser and enjoy 22 | 23 | ## Tech Stack 24 | 25 | - **React** for building the user interface 26 | - **Next.js** for server rendered React applications 27 | - **Styled Components** for CSS styling 28 | - **useSWR** for fetching an API 29 | 30 | ## Features 31 | 32 | If you are a passionate traveler, this travel app offers some features to make your travel experience easier and more enjoyable. Here are the key features that our app provides 33 | 34 | - Visually displayed progress bars on the homepage, showcasing the number of countries you have visited so far 35 | - A comprehensive list of all countries where you can filter by continent or search for a country by name 36 | - The ability to mark countries as "visited" or "want to go" directly from the country list 37 | - Clicking on a country's name will take you to a detailed page that displays useful information such as the country's continent, languages, currencies, and current local time 38 | - The "Explored" page displays all the countries that you have marked as "visited" and sorts them alphabetically 39 | - The "Wish list" page displays all the countries that you have marked as "want to go" and sorts them alphabetically 40 | - The "Plan your trip" button takes you to a form where you can enter the details of your upcoming trip and submit them. You can also edit or delete these details at any time 41 | 42 | ## Acknowledgements 43 | 44 | This project is the final capstome project implemented during the Web Development Bootcamp at neuefische. I would like to thank the entire team of neuefische for their guidance and support throughout the whole process! 45 | -------------------------------------------------------------------------------- /pages/favorites.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import CountriesPreview from "../components/CountriesPreview"; 3 | import FavoriteButton from "../components/FavoriteButton"; 4 | import Form from "../components/Form"; 5 | import Header from "../components/Header"; 6 | import StyledList from "../components/StyledList"; 7 | import StyledListElement from "../components/StyledListElement"; 8 | import StyledSVG from "../components/StyledSVG"; 9 | import StyledButton from "../components/StyledButton"; 10 | import useLocalStorageState from "use-local-storage-state"; 11 | import Entry from "../components/Entry"; 12 | import { Fragment } from "react"; 13 | 14 | export default function FavoriteCountriesPage({ 15 | countries, 16 | countriesInfo, 17 | onToggleFavorite, 18 | }) { 19 | const [entries, setEntries] = useLocalStorageState("entries", { 20 | defaultValue: [], 21 | }); 22 | 23 | const [selectedCountry, setSelectedCountry] = useState(""); 24 | 25 | const [isDeleted, setIsDeleted] = useState(false); 26 | 27 | const listFavoriteCountries = countriesInfo.filter((info) => info.isFavorite); 28 | 29 | const favoriteCountries = countries.filter((country) => 30 | listFavoriteCountries.find((info) => info.name === country.name.common) 31 | ); 32 | 33 | function handleAddEntry(newEntry) { 34 | setEntries([{ ...newEntry }, ...entries]); 35 | } 36 | 37 | function handleEditEntry(country, updatedEntry) { 38 | setEntries( 39 | entries.map((entry) => { 40 | const entryToEdit = entry.name === country.name.common; 41 | if (entryToEdit) { 42 | return { 43 | name: entry.name, 44 | date: updatedEntry.date, 45 | passport: updatedEntry.passport, 46 | visa: updatedEntry.visa, 47 | vaccination: updatedEntry.vaccination, 48 | allowedDays: updatedEntry.allowedDays, 49 | notes: updatedEntry.notes, 50 | }; 51 | } else { 52 | return entry; 53 | } 54 | }) 55 | ); 56 | } 57 | 58 | function handleDeleteEntry(country) { 59 | setIsDeleted(!setIsDeleted); 60 | setEntries(entries.filter((entry) => entry.name !== country.name.common)); 61 | setSelectedCountry(""); 62 | } 63 | 64 | return ( 65 | <> 66 |
67 | 68 | {favoriteCountries 69 | .slice() 70 | .sort((a, b) => a.name.common.localeCompare(b.name.common)) 71 | .map((country) => { 72 | const isCountrySelected = selectedCountry === country.name.common; 73 | return ( 74 | 75 | 76 | 85 | 86 | 91 | 92 | {isCountrySelected && ( 93 |
97 | )} 98 | {!entries.find( 99 | (entry) => entry.name === country.name.common 100 | ) && ( 101 | 104 | setSelectedCountry( 105 | !isCountrySelected && country.name.common 106 | ) 107 | } 108 | > 109 | {isDeleted 110 | ? "Plan my trip" 111 | : isCountrySelected 112 | ? "Hide form" 113 | : "Plan my trip"} 114 | 115 | )} 116 | 123 | 124 | 125 | ); 126 | })} 127 | 128 | 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /components/CountryDetails/index.js: -------------------------------------------------------------------------------- 1 | import StyledDiv from "../StyledDiv"; 2 | import StyledFlag from "../StyledFlag"; 3 | import styled from "styled-components"; 4 | import Link from "next/link"; 5 | import Arrow from "../../public/assets/arrow-left.svg"; 6 | 7 | const StyledLink = styled(Link)` 8 | font-size: 2rem; 9 | position: absolute; 10 | top: 5.5rem; 11 | align-self: flex-start; 12 | `; 13 | 14 | export default function CountryDetails({ selectedCountry }) { 15 | function addSeparators(number) { 16 | return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, "."); 17 | } 18 | if (!selectedCountry) { 19 | return

is Loading

; 20 | } 21 | 22 | const languagesWithSeparators = 23 | selectedCountry.languages && 24 | Object.values(selectedCountry.languages).join(", "); 25 | 26 | const timezoneWithSeparators = selectedCountry.timezones?.join(", "); 27 | 28 | function getLocalTime(selectedCountry) { 29 | const timeZoneMap = { 30 | "UTC+01:00": "Europe/Paris", 31 | "UTC+02:00": "Europe/Helsinki", 32 | "UTC+03:00": "Europe/Moscow", 33 | "UTC+03:30": "Asia/Tehran", 34 | "UTC+04:00": "Asia/Dubai", 35 | "UTC+04:30": "Asia/Kabul", 36 | "UTC+05:00": "Asia/Karachi", 37 | "UTC+05:30": "Asia/Calcutta", 38 | "UTC+05:45": "Asia/Katmandu", 39 | "UTC+06:00": "Asia/Dhaka", 40 | "UTC+06:30": "Asia/Rangoon", 41 | "UTC+07:00": "Asia/Bangkok", 42 | "UTC+08:00": "Asia/Shanghai", 43 | "UTC+09:00": "Asia/Tokyo", 44 | "UTC+09:30": "Australia/Darwin", 45 | "UTC+10:00": "Australia/Sydney", 46 | "UTC+10:30": "Australia/Lord_Howe", 47 | "UTC+11:00": "Pacific/Guadalcanal", 48 | "UTC+11:30": "Pacific/Norfolk", 49 | "UTC+12:00": "Pacific/Auckland", 50 | "UTC+12:45": "Pacific/Chatham", 51 | "UTC+13:00": "Pacific/Tongatapu", 52 | "UTC+14:00": "Pacific/Kiritimati", 53 | "UTC-01:00": "Atlantic/Azores", 54 | "UTC-02:00": "America/Noronha", 55 | "UTC-03:00": "America/Sao_Paulo", 56 | "UTC-03:30": "America/St_Johns", 57 | "UTC-04:00": "America/Caracas", 58 | "UTC-05:00": "America/Bogota", 59 | "UTC-06:00": "America/Mexico_City", 60 | "UTC-07:00": "America/Phoenix", 61 | "UTC-08:00": "America/Los_Angeles", 62 | "UTC-09:00": "America/Anchorage", 63 | "UTC-09:30": "Pacific/Marquesas", 64 | "UTC-10:00": "Pacific/Honolulu", 65 | "UTC-11:00": "Pacific/Midway", 66 | "UTC-12:00": "Pacific/Kwajalein", 67 | UTC: "Europe/London", 68 | }; 69 | 70 | const selectedTimeZone = selectedCountry.timezones.filter((timezone) => 71 | timeZoneMap.hasOwnProperty(timezone) 72 | ); 73 | 74 | const localTimes = selectedTimeZone.map((timezone) => 75 | new Date().toLocaleString("en-US", { 76 | timeZone: timeZoneMap[timezone], 77 | }) 78 | ); 79 | return localTimes.map((localTime) =>
  • {localTime}
  • ); 80 | } 81 | 82 | return ( 83 | 84 | 85 | 86 | 87 |

    {selectedCountry.name.common}

    88 | 89 | Continent: {selectedCountry.continents} 90 | 91 | 92 | Subregion: {selectedCountry.subregion} 93 | 94 | 95 | Capital: {selectedCountry.capital} 96 | 97 | 98 | Population: {addSeparators(selectedCountry.population)} 99 | 100 | 101 | 102 | {Object.keys(selectedCountry?.languages || selectedCountry.timezones) 103 | .length > 1 104 | ? "Languages: " 105 | : "Language: "} 106 | 107 | {languagesWithSeparators} 108 | 109 | 110 | { 111 | 112 | Currency: 113 | {selectedCountry.currencies && 114 | Object.values(selectedCountry.currencies).map((currency, index) => ( 115 | {currency.name} 116 | ))} 117 | 118 | } 119 | 120 | 121 | {selectedCountry.timezones.length > 1 ? "Timezones:" : "Timezone:"} 122 | {" "} 123 | {selectedCountry.timezones.length > 1 &&
    } 124 | {timezoneWithSeparators} 125 |
    126 | 127 | 128 | {selectedCountry.timezones.length > 1 129 | ? "Current local times:" 130 | : "Current local time:"} 131 | {" "} 132 | {getLocalTime(selectedCountry)} 133 | 134 | 135 | UN Member: {selectedCountry.unMember ? "Yes" : "No"} 136 | 137 | {selectedCountry.flag} 138 |
    139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /components/Form/index.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import StyledButton from "../StyledButton"; 3 | import StyledForm from "./StyledForm"; 4 | import StyledFieldset from "./StyledFieldset"; 5 | 6 | export default function Form({ name, onAddEntry }) { 7 | const [isVisaValid, setIsVisaValid] = useState(true); 8 | const [text, setText] = useState(""); 9 | const [isFormSubmitted, setIsFormSubmitted] = useState(false); 10 | 11 | function handleChange(event) { 12 | setText(event.target.value); 13 | } 14 | 15 | function handleSubmit(event) { 16 | event.preventDefault(); 17 | const formData = new FormData(event.target); 18 | const data = Object.fromEntries(formData); 19 | 20 | if (data.allowedDays < 0 || data.allowedDays > 365) { 21 | alert("The allowed days must be between 0 and 365. Please try again."); 22 | return; 23 | } 24 | 25 | onAddEntry(data); 26 | setText(""); 27 | setIsFormSubmitted(true); 28 | } 29 | 30 | return ( 31 | <> 32 | {!isFormSubmitted && ( 33 | 34 |

    Plan my trip

    35 | 36 | 37 |

    Travel Details

    38 |
    39 | 42 | 43 | 44 | 52 |
    53 |

    Required checks:

    54 | 55 | 56 | Passport: 57 | 58 | 68 | 78 | 79 | 80 | 81 | Vaccination: 82 | 83 | 93 | 103 | 104 | 105 | 106 | Visa: 107 | 108 | 118 | 128 | 129 | 130 | 131 |

    Additional checks:

    132 |
    133 | 134 | { 141 | event.target.value > 365 142 | ? setIsVisaValid(false) 143 | : setIsVisaValid(true); 144 | }} 145 | > 146 | 147 | {isVisaValid 148 | ? "Value must be between 1 and 365" 149 | : "You are above max-value of 365"} 150 | 151 |
    152 | Notes: 153 | 165 | Characters left: {400 - text.length}/400 166 |
    167 | 168 | Submit 169 | 170 |
    171 | )} 172 | 173 | ); 174 | } 175 | -------------------------------------------------------------------------------- /components/Entry/index.js: -------------------------------------------------------------------------------- 1 | import StyledList from "../StyledList"; 2 | import StyledButton from "../StyledButton"; 3 | import StyledForm from "../Form/StyledForm"; 4 | import { useState } from "react"; 5 | import StyledFieldset from "../Form/StyledFieldset"; 6 | import styled from "styled-components"; 7 | 8 | const StyledButtonWrapper = styled.div` 9 | display: flex; 10 | justify-content: space-between; 11 | gap: 0.5rem; 12 | margin-top: 1rem; 13 | `; 14 | 15 | export default function Entry({ 16 | entries, 17 | name, 18 | onEditEntry, 19 | country, 20 | onDeleteEntry, 21 | }) { 22 | const [isVisaValid, setIsVisaValid] = useState(true); 23 | const [isEditing, setIsEditing] = useState(false); 24 | const [remainingCharacters, setRemainingCharacters] = useState(400); 25 | const selectedCountry = entries.find((entry) => entry.name === name); 26 | 27 | function handleSubmit(event) { 28 | event.preventDefault(); 29 | const formData = new FormData(event.target); 30 | const data = Object.fromEntries(formData); 31 | 32 | if (data.allowedDays < 0 || data.allowedDays > 365) { 33 | alert("The allowed days must be between 0 and 365. Please try again."); 34 | return; 35 | } 36 | 37 | onEditEntry(country, data); 38 | setIsEditing(!isEditing); 39 | } 40 | 41 | function handleCharacterLengthChange(event) { 42 | setRemainingCharacters(400 - event.target.value.length); 43 | } 44 | 45 | return ( 46 | selectedCountry && ( 47 | <> 48 | 49 | {selectedCountry.date && ( 50 |
  • 51 | When:{" "} 52 | {new Date(selectedCountry.date).toLocaleString("en-US", { 53 | month: "long", 54 | year: "numeric", 55 | })} 56 |
  • 57 | )} 58 | {selectedCountry.passport && ( 59 |
  • 60 | Passport required: {selectedCountry.passport} 61 |
  • 62 | )} 63 | {selectedCountry.vaccination && ( 64 |
  • 65 | Vaccination required: 66 | {selectedCountry.vaccination} 67 |
  • 68 | )} 69 | {selectedCountry.visa && ( 70 |
  • 71 | Visa required: {selectedCountry.visa} 72 |
  • 73 | )} 74 | {selectedCountry.allowedDays && ( 75 |
  • 76 | Allowed days: {selectedCountry.allowedDays} 77 |
  • 78 | )} 79 | {selectedCountry.notes && ( 80 |
  • 81 | Notes: {selectedCountry.notes} 82 |
  • 83 | )} 84 | 85 | 86 | setIsEditing(!isEditing)}> 87 | {isEditing ? "Cancel" : "Edit"} 88 | 89 | onDeleteEntry(country)}> 90 | Delete 91 | 92 | 93 |
    94 | {isEditing && ( 95 | <> 96 | 97 |

    Edit my planned trip

    98 | 99 | 100 |

    Travel Details

    101 |
    102 | 103 | 112 |
    113 |

    Required checks:

    114 | 115 | 116 | Passport: 117 | 118 | 131 | 144 | 145 | 146 | 147 | Vaccination: 148 | 149 | 162 | 175 | 176 | 177 | 178 | Visa: 179 | 180 | 193 | 206 | 207 | 208 | 209 |

    Additional checks:

    210 |
    211 | 212 | { 219 | event.target.value > 365 220 | ? setIsVisaValid(false) 221 | : setIsVisaValid(true); 222 | }} 223 | defaultValue={selectedCountry.allowedDays} 224 | /> 225 | 226 | {isVisaValid 227 | ? "Value must be between 1 and 365" 228 | : "You are above max-value of 365"} 229 | 230 |
    231 | Notes: 232 |