├── 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 |
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 |
21 | {" "}
22 |
28 |
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 |
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 |
53 |
54 | Filter by Continent...
55 | Africa
56 | Antarctica
57 | Asia
58 | Central America
59 | Europe
60 | North America
61 | Oceania
62 | South America
63 |
64 |
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 | 
10 | 
11 | 
12 |
13 | 
14 | 
15 | 
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 |
40 | Countryname:
41 |
42 |
43 | When:
44 |
52 |
53 | Required checks:
54 |
55 |
56 | Passport:
57 |
58 |
59 |
66 | YES
67 |
68 |
69 |
76 | NO
77 |
78 |
79 |
80 |
81 | Vaccination:
82 |
83 |
84 |
91 | YES
92 |
93 |
94 |
101 | NO
102 |
103 |
104 |
105 |
106 | Visa:
107 |
108 |
109 |
116 | YES
117 |
118 |
119 |
126 | NO
127 |
128 |
129 |
130 |
131 | Additional checks:
132 |
133 | Allowed days to stay:
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 |
154 |
164 |
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 | When:
103 |
112 |
113 | Required checks:
114 |
115 |
116 | Passport:
117 |
118 |
119 |
129 | YES
130 |
131 |
132 |
142 | NO
143 |
144 |
145 |
146 |
147 | Vaccination:
148 |
149 |
150 |
160 | YES
161 |
162 |
163 |
173 | NO
174 |
175 |
176 |
177 |
178 | Visa:
179 |
180 |
181 |
191 | YES
192 |
193 |
194 |
204 | NO
205 |
206 |
207 |
208 |
209 | Additional checks:
210 |
211 | Allowed days to stay:
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 |
233 |
243 |
244 | Characters left: {remainingCharacters}/400
245 |
246 |
247 |
248 | Save your trip
249 |
250 |
251 | >
252 | )}
253 | >
254 | )
255 | );
256 | }
257 |
--------------------------------------------------------------------------------