From 62b4b53fb4085e9a21167532958e950331dbfb02 Mon Sep 17 00:00:00 2001
From: Kacper Donat <kadet1090@gmail.com>
Date: Tue, 8 Sep 2020 20:54:05 +0200
Subject: [PATCH 1/2] Add async state management

---
 src/api/edition.ts             |  3 ++
 src/helpers.ts                 |  4 +++
 src/hooks/index.ts             |  1 +
 src/hooks/useAsync.ts          | 50 ++++++++++++++++++++++++++++++++++
 src/pages/edition/register.tsx | 32 +++++++++++++---------
 5 files changed, 77 insertions(+), 13 deletions(-)
 create mode 100644 src/hooks/useAsync.ts

diff --git a/src/api/edition.ts b/src/api/edition.ts
index 966a036..bce8982 100644
--- a/src/api/edition.ts
+++ b/src/api/edition.ts
@@ -1,6 +1,7 @@
 import { axios } from "@/api/index";
 import { Edition } from "@/data/edition";
 import { sampleEdition } from "@/provider/dummy";
+import { delay } from "@/helpers";
 
 const EDITIONS_ENDPOINT = "/editions";
 const EDITION_INFO_ENDPOINT = "/editions/:key";
@@ -24,6 +25,8 @@ export async function join(key: string): Promise<boolean> {
 
 // MOCK
 export async function get(key: string): Promise<Edition | null> {
+    await delay(Math.random() * 200 + 100);
+
     if (key == "inf2020") {
         return sampleEdition;
     }
diff --git a/src/helpers.ts b/src/helpers.ts
index b24217f..ae9da92 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -8,3 +8,7 @@ export type Index = string | symbol | number;
 export interface DOMEvent<TTarget extends EventTarget> extends Event {
     target: TTarget;
 }
+
+export function delay(time: number) {
+  return new Promise(resolve => setTimeout(resolve, time));
+}
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 9e7f847..848cfd7 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -1,2 +1,3 @@
 export * from "./useProxyState"
 export * from "./useUpdateEffect"
+export * from "./useAsync"
diff --git a/src/hooks/useAsync.ts b/src/hooks/useAsync.ts
new file mode 100644
index 0000000..91e1de2
--- /dev/null
+++ b/src/hooks/useAsync.ts
@@ -0,0 +1,50 @@
+import { useEffect, useState } from "react";
+
+export type AsyncResult<T, TError = any> = {
+    isLoading: boolean,
+    value: T | undefined,
+    error: TError | undefined
+};
+
+export type AsyncState<T, TError = any> = [AsyncResult<T, TError>, (promise: Promise<T> | undefined) => void]
+
+export function useAsync<T, TError = any>(promise: Promise<T> | undefined): AsyncResult<T, TError> {
+    const [isLoading, setLoading] = useState<boolean>(true);
+    const [error, setError] = useState<TError | undefined>(undefined);
+    const [value, setValue] = useState<T | undefined>(undefined);
+    const [semaphore] = useState<{ value: number }>({ value: 0 })
+
+    useEffect(() => {
+        setLoading(true);
+        setError(undefined);
+        setValue(undefined);
+
+        const myMagicNumber = semaphore.value + 1;
+        semaphore.value = myMagicNumber;
+
+        promise && promise.then(value => {
+            if (semaphore.value == myMagicNumber) {
+                setValue(value);
+                setLoading(false);
+            }
+        }).catch(error => {
+            if (semaphore.value == myMagicNumber) {
+                setError(error);
+                setLoading(false);
+            }
+        })
+    }, [ promise ])
+
+    return {
+        isLoading,
+        value,
+        error,
+    };
+}
+
+export function useAsyncState<T, TError = any>(initial: Promise<T> | undefined): AsyncState<T, TError> {
+    const [promise, setPromise] = useState<Promise<T> | undefined>(initial);
+    const asyncState = useAsync(promise);
+
+    return [ asyncState, setPromise ];
+}
diff --git a/src/pages/edition/register.tsx b/src/pages/edition/register.tsx
index a10a105..7c69834 100644
--- a/src/pages/edition/register.tsx
+++ b/src/pages/edition/register.tsx
@@ -1,11 +1,12 @@
 import React, { useEffect, useState } from "react";
 import { Page } from "@/pages/base";
 import { useTranslation } from "react-i18next";
-import { Button, Container, TextField, Typography } from "@material-ui/core";
+import { Box, Button, CircularProgress, Container, TextField, Typography } from "@material-ui/core";
 
 import api from "@/api";
 import { useVerticalSpacing } from "@/styles";
 import { Edition } from "@/data/edition";
+import { useAsyncState } from "@/hooks";
 import { Label, Section } from "@/components/section";
 import { Alert } from "@material-ui/lab";
 
@@ -13,12 +14,12 @@ export const RegisterEditionPage = () => {
     const { t } = useTranslation();
 
     const [key, setKey] = useState<string>("");
-    const [edition, setEdition] = useState<Edition | null>(null);
+    const [{ value: edition, isLoading }, setEdition] = useAsyncState<Edition | null>(undefined);
 
     const classes = useVerticalSpacing(3);
 
     useEffect(() => {
-        (async () => setEdition(await api.edition.get(key)))();
+        setEdition(api.edition.get(key));
     }, [ key ])
 
     const handleRegister = () => {
@@ -33,6 +34,17 @@ export const RegisterEditionPage = () => {
         setKey(ev.currentTarget.value);
     }
 
+    const Edition = () => edition
+        ? <Section>
+            <Label>{ t("forms.edition-register.edition" ) }</Label>
+            <Typography className="proposal__primary">{ edition.course.name }</Typography>
+            <Typography className="proposal__secondary">
+                { t('internship.date-range', { start: edition.startDate, end: edition.endDate }) }
+            </Typography>
+        </Section>
+        : <Alert severity="warning">{ t("forms.edition-register.edition-not-found") }</Alert>
+
+
     return <Page>
         <Page.Header maxWidth="md">
             <Page.Title>{ t("pages.edition.header") }</Page.Title>
@@ -42,16 +54,10 @@ export const RegisterEditionPage = () => {
             <TextField label={ t("forms.edition-register.fields.key") } fullWidth
                        onChange={ handleKeyChange }
                        value={ key } />
-            { edition
-                ? <Section>
-                    <Label>{ t("forms.edition-register.edition" ) }</Label>
-                    <Typography className="proposal__primary">{ edition.course.name }</Typography>
-                    <Typography className="proposal__secondary">
-                        { t('internship.date-range', { start: edition.startDate, end: edition.endDate }) }
-                    </Typography>
-                </Section>
-                : <Alert severity="warning">{ t("forms.edition-register.edition-not-found") }</Alert>
-            }
+
+            <Box>
+                { isLoading ? <CircularProgress /> : <Edition /> }
+            </Box>
 
             <Button onClick={ handleRegister } variant="contained" color="primary" disabled={ !edition }>{ t("forms.edition-register.register") }</Button>
         </Container>
-- 
2.45.2


From aa472e12379bc6ebc754de996955de204faf4fa3 Mon Sep 17 00:00:00 2001
From: Kacper Donat <kadet1090@gmail.com>
Date: Tue, 8 Sep 2020 21:39:00 +0200
Subject: [PATCH 2/2] Add page management

---
 src/api/index.ts               |  4 ++-
 src/api/page.tsx               | 27 ++++++++++++++++++
 src/app.tsx                    |  8 ++++--
 src/data/common.ts             |  5 ++++
 src/data/page.ts               |  6 ++++
 src/pages/errors/not-found.tsx | 22 ---------------
 src/pages/fallback.tsx         | 51 ++++++++++++++++++++++++++++++++++
 src/pages/index.ts             |  2 +-
 src/routing.tsx                |  4 +--
 src/styles/header.scss         | 44 +++++++++++++++++++++++++++++
 10 files changed, 145 insertions(+), 28 deletions(-)
 create mode 100644 src/api/page.tsx
 create mode 100644 src/data/page.ts
 delete mode 100644 src/pages/errors/not-found.tsx
 create mode 100644 src/pages/fallback.tsx

diff --git a/src/api/index.ts b/src/api/index.ts
index 2fab26e..6d9942d 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -5,6 +5,7 @@ import { UserState } from "@/state/reducer/user";
 
 import * as user from "./user";
 import * as edition from "./edition";
+import * as page from "./page"
 
 export const axios = Axios.create({
     baseURL: process.env.API_BASE_URL || "https://system-praktyk.stg.kadet.net/api/",
@@ -29,7 +30,8 @@ axios.interceptors.request.use(config => {
 
 const api = {
     user,
-    edition
+    edition,
+    page
 }
 
 export default api;
diff --git a/src/api/page.tsx b/src/api/page.tsx
new file mode 100644
index 0000000..f1c69ee
--- /dev/null
+++ b/src/api/page.tsx
@@ -0,0 +1,27 @@
+// MOCK
+import { Page } from "@/data/page";
+
+const tos = `<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Bestiarum vero nullum iudicium puto. Quare ad ea primum, si videtur; <b>Duo Reges: constructio interrete.</b> <i>Eam tum adesse, cum dolor omnis absit;</i> Sed ad bona praeterita redeamus. <mark>Facillimum id quidem est, inquam.</mark> Apud ceteros autem philosophos, qui quaesivit aliquid, tacet; </p>
+
+<p><a href="http://loripsum.net/" target="_blank">Quorum altera prosunt, nocent altera.</a> Eam stabilem appellas. <i>Sed nimis multa.</i> Quo plebiscito decreta a senatu est consuli quaestio Cn. Sin laboramus, quis est, qui alienae modum statuat industriae? <mark>Quod quidem nobis non saepe contingit.</mark> Si autem id non concedatur, non continuo vita beata tollitur. <a href="http://loripsum.net/" target="_blank">Illum mallem levares, quo optimum atque humanissimum virum, Cn.</a> <i>Id est enim, de quo quaerimus.</i> </p>
+
+<p>Ille vero, si insipiens-quo certe, quoniam tyrannus -, numquam beatus; Sin dicit obscurari quaedam nec apparere, quia valde parva sint, nos quoque concedimus; Et quod est munus, quod opus sapientiae? Ab hoc autem quaedam non melius quam veteres, quaedam omnino relicta. </p>
+`
+
+export async function get(slug: string): Promise<Page> {
+    if (slug === "/regulamin" || slug === "/rules") {
+        return {
+            id: "tak",
+            content: {
+                pl: tos,
+                en: tos,
+            },
+            title: {
+                pl: "Regulamin Praktyk",
+                en: "Terms of Internship",
+            },
+        }
+    }
+
+    throw new Error();
+}
diff --git a/src/app.tsx b/src/app.tsx
index d879206..c05b38c 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -91,13 +91,17 @@ function App() {
             </div>
             <div className="header__nav">
                 <nav className="header__top">
-                    <ul className="header__menu"></ul>
+                    <ul className="header__menu">
+                    </ul>
                     <UserMenu className="header__user"/>
                     <div className="header__divider"/>
                     <LanguageSwitcher className="header__language-switcher"/>
                 </nav>
                 <nav className="header__bottom">
-                    <ul className="header__menu header__menu--main"></ul>
+                    <ul className="header__menu header__menu--main">
+                        <li><Link to={ route("home") }>{ t("pages.my-internship.header") }</Link></li>
+                        <li><Link to="/regulamin">Regulamin</Link></li>
+                    </ul>
                 </nav>
             </div>
         </header>
diff --git a/src/data/common.ts b/src/data/common.ts
index 5a735c4..da78cf8 100644
--- a/src/data/common.ts
+++ b/src/data/common.ts
@@ -3,3 +3,8 @@ export type Identifier = string;
 export interface Identifiable {
     id?: Identifier
 }
+
+export type Multilingual<T> = {
+    pl: T,
+    en: T
+}
diff --git a/src/data/page.ts b/src/data/page.ts
new file mode 100644
index 0000000..a474e82
--- /dev/null
+++ b/src/data/page.ts
@@ -0,0 +1,6 @@
+import { Identifiable, Multilingual } from "@/data/common";
+
+export interface Page extends Identifiable {
+    title: Multilingual<string>;
+    content: Multilingual<string>;
+}
diff --git a/src/pages/errors/not-found.tsx b/src/pages/errors/not-found.tsx
deleted file mode 100644
index 0f6456b..0000000
--- a/src/pages/errors/not-found.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Page } from "@/pages/base";
-import { Box, Button, Container, Divider, Typography } from "@material-ui/core";
-import { route } from "@/routing";
-import { Link as RouterLink } from "react-router-dom";
-import React from "react";
-
-export const NotFoundPage = () => {
-    return <Page title="Strona nie została znaleziona">
-        <Container>
-            <Typography variant="h1">404</Typography>
-            <Typography variant="h2">Strona nie została znaleziona</Typography>
-
-            <Box my={ 4 }>
-                <Divider variant="fullWidth"/>
-            </Box>
-
-            <Button to={ route("home") } color="primary" component={ RouterLink }>strona główna</Button>
-        </Container>
-    </Page>
-}
-
-export default NotFoundPage;
diff --git a/src/pages/fallback.tsx b/src/pages/fallback.tsx
new file mode 100644
index 0000000..ff1584f
--- /dev/null
+++ b/src/pages/fallback.tsx
@@ -0,0 +1,51 @@
+import { Page } from "@/pages/base";
+import { Box, Button, CircularProgress, Container, Divider, Typography } from "@material-ui/core";
+import { Link as RouterLink, useLocation } from "react-router-dom";
+import React, { useMemo } from "react";
+import { route } from "@/routing";
+import { useAsync } from "@/hooks";
+import api from "@/api";
+
+export const FallbackPage = () => {
+    const location = useLocation();
+
+    const promise = useMemo(() => api.page.get(location.pathname), [ location.pathname ]);
+
+    const { isLoading, value, error } = useAsync(promise);
+
+    console.log({ isLoading, value, error, location });
+
+    if (isLoading) {
+        return <CircularProgress />
+    }
+
+    if (error) {
+        return <Page title="Strona nie została znaleziona">
+            <Container>
+                <Typography variant="h1">404</Typography>
+                <Typography variant="h2">Strona nie została znaleziona</Typography>
+
+                <Box my={ 4 }>
+                    <Divider variant="fullWidth"/>
+                </Box>
+
+                <Button to={ route("home") } color="primary" component={ RouterLink }>strona główna</Button>
+            </Container>
+        </Page>
+    }
+
+    if (value) {
+        return <Page title={ value.title.pl }>
+            <Page.Header maxWidth="md">
+                <Page.Title>{ value.title.pl }</Page.Title>
+            </Page.Header>
+            <Container>
+                <div dangerouslySetInnerHTML={{ __html: value.content.pl }} />
+            </Container>
+        </Page>
+    }
+
+    return <Page/>;
+}
+
+export default FallbackPage;
diff --git a/src/pages/index.ts b/src/pages/index.ts
index c5f71a3..b4836aa 100644
--- a/src/pages/index.ts
+++ b/src/pages/index.ts
@@ -1,3 +1,3 @@
 export * from "./internship/proposal";
-export * from "./errors/not-found"
 export * from "./main"
+export * from "./fallback"
diff --git a/src/routing.tsx b/src/routing.tsx
index 233b423..f57249d 100644
--- a/src/routing.tsx
+++ b/src/routing.tsx
@@ -2,7 +2,7 @@ import React, { ReactComponentElement } from "react";
 import { MainPage } from "@/pages/main";
 import { RouteProps } from "react-router-dom";
 import { InternshipProposalFormPage, InternshipProposalPreviewPage } from "@/pages/internship/proposal";
-import { NotFoundPage } from "@/pages/errors/not-found";
+import { FallbackPage } from "@/pages/fallback";
 import SubmitPlanPage from "@/pages/internship/plan";
 import { UserLoginPage } from "@/pages/user/login";
 import { RegisterEditionPage } from "@/pages/edition/register";
@@ -28,7 +28,7 @@ export const routes: Route[] = [
     { name: "user_login", path: "/user/login", exact: true, content: () => <UserLoginPage /> },
 
     // fallback route for 404 pages
-    { name: "fallback", path: "*", content: () => <NotFoundPage/> }
+    { name: "fallback", path: "*", content: () => <FallbackPage/> }
 ]
 
 const routeNameMap = new Map(routes.filter(({ name }) => !!name).map(({ name, path }) => [name, path instanceof Array ? path[0] : path])) as Map<string, string>
diff --git a/src/styles/header.scss b/src/styles/header.scss
index 9750554..60f2400 100644
--- a/src/styles/header.scss
+++ b/src/styles/header.scss
@@ -45,12 +45,56 @@
 
 .header__nav {
   flex: 1 1 auto;
+  display: flex;
+  flex-direction: column;
 }
 
 .header__top .header__menu {
   margin-right: auto;
 }
 
+.header__bottom {
+  display: flex;
+  flex: 1 1 auto;
+}
+
+.header__menu--main {
+  display: flex;
+  list-style: none;
+  margin: 0;
+  font-size: 1.25rem;
+  padding-left: 0.75rem;
+
+  > li {
+    display: flex;
+
+    > a {
+      padding: 16px;
+      color: white;
+      text-decoration: none;
+      font-weight: bold;
+      display: flex;
+      align-items: center;
+      position: relative;
+
+      &:hover {
+        background: $brand;
+
+        &::before {
+          display: block;
+          bottom: 0;
+          left: 0;
+          right: 0;
+          height: 4px;
+          position: absolute;
+          background: white;
+          content: '';
+        }
+      }
+    }
+  }
+}
+
 .header__language-switcher {
   padding: 0;
   display: flex;
-- 
2.45.2