diff --git a/03_source/mobile/src/pages/DemoReactQuotes/AppPages/Tab1.jsx b/03_source/mobile/src/pages/DemoReactQuotes/AppPages/Tab1.tsx
similarity index 100%
rename from 03_source/mobile/src/pages/DemoReactQuotes/AppPages/Tab1.jsx
rename to 03_source/mobile/src/pages/DemoReactQuotes/AppPages/Tab1.tsx
diff --git a/03_source/mobile/src/pages/DemoReactQuotes/AppPages/Tab2.jsx b/03_source/mobile/src/pages/DemoReactQuotes/AppPages/Tab2.tsx
similarity index 100%
rename from 03_source/mobile/src/pages/DemoReactQuotes/AppPages/Tab2.jsx
rename to 03_source/mobile/src/pages/DemoReactQuotes/AppPages/Tab2.tsx
diff --git a/03_source/mobile/src/pages/DemoReactQuotes/components/Menu.css b/03_source/mobile/src/pages/DemoReactQuotes/components/Menu.css
new file mode 100644
index 0000000..0ca47a2
--- /dev/null
+++ b/03_source/mobile/src/pages/DemoReactQuotes/components/Menu.css
@@ -0,0 +1,113 @@
+ion-menu ion-content {
+ --background: var(--ion-item-background, var(--ion-background-color, #fff));
+}
+
+ion-menu.md ion-content {
+ --padding-start: 8px;
+ --padding-end: 8px;
+ --padding-top: 20px;
+ --padding-bottom: 20px;
+}
+
+ion-menu.md ion-list {
+ padding: 20px 0;
+}
+
+ion-menu.md ion-note {
+ margin-bottom: 30px;
+}
+
+ion-menu.md ion-list-header, ion-menu.md ion-note {
+ padding-left: 10px;
+}
+
+ion-menu.md ion-list#inbox-list {
+ border-bottom: 1px solid var(--ion-color-step-150, #d7d8da);
+}
+
+ion-menu.md ion-list#inbox-list ion-list-header {
+ font-size: 22px;
+ font-weight: 600;
+ min-height: 20px;
+}
+
+ion-menu.md ion-list#labels-list ion-list-header {
+ font-size: 16px;
+ margin-bottom: 18px;
+ color: #757575;
+ min-height: 26px;
+}
+
+ion-menu.md ion-item {
+ --padding-start: 10px;
+ --padding-end: 10px;
+ border-radius: 4px;
+}
+
+ion-menu.md ion-item.selected {
+ --background: rgba(var(--ion-color-primary-rgb), 0.14);
+}
+
+ion-menu.md ion-item.selected ion-icon {
+ color: var(--ion-color-primary);
+}
+
+ion-menu.md ion-item ion-icon {
+ color: #616e7e;
+}
+
+ion-menu.md ion-item ion-label {
+ font-weight: 500;
+}
+
+ion-menu.ios ion-content {
+ --padding-bottom: 20px;
+}
+
+ion-menu.ios ion-list {
+ padding: 20px 0 0 0;
+}
+
+ion-menu.ios ion-note {
+ line-height: 24px;
+ margin-bottom: 20px;
+}
+
+ion-menu.ios ion-item {
+ --padding-start: 16px;
+ --padding-end: 16px;
+ --min-height: 50px;
+}
+
+ion-menu.ios ion-item ion-icon {
+ font-size: 24px;
+ color: #73849a;
+}
+
+ion-menu.ios ion-item .selected ion-icon {
+ color: var(--ion-color-primary);
+}
+
+ion-menu.ios ion-list#labels-list ion-list-header {
+ margin-bottom: 8px;
+}
+
+ion-menu.ios ion-list-header,
+ion-menu.ios ion-note {
+ padding-left: 16px;
+ padding-right: 16px;
+}
+
+ion-menu.ios ion-note {
+ margin-bottom: 8px;
+}
+
+ion-note {
+ display: inline-block;
+ font-size: 16px;
+ color: var(--ion-color-medium-shade);
+}
+
+ion-item.selected {
+ --color: var(--ion-color-primary);
+}
\ No newline at end of file
diff --git a/03_source/mobile/src/pages/DemoReactQuotes/components/Menu.tsx b/03_source/mobile/src/pages/DemoReactQuotes/components/Menu.tsx
new file mode 100644
index 0000000..c42c9e3
--- /dev/null
+++ b/03_source/mobile/src/pages/DemoReactQuotes/components/Menu.tsx
@@ -0,0 +1,62 @@
+import {
+ IonContent,
+ IonIcon,
+ IonItem,
+ IonLabel,
+ IonList,
+ IonListHeader,
+ IonMenu,
+ IonMenuToggle,
+ IonNote,
+} from '@ionic/react';
+
+import { useLocation } from 'react-router-dom';
+import { bookmarkOutline, bookmarkSharp, homeOutline, homeSharp } from 'ionicons/icons';
+import './Menu.css';
+import { useStoreState } from 'pullstate';
+import { QuoteStore } from '../store';
+import { getSavedQuotes } from '../store/Selectors';
+
+const Menu = () => {
+
+ const location = useLocation();
+ const saved = useStoreState(QuoteStore, getSavedQuotes);
+
+ const appPages = [
+ {
+ title: 'Home',
+ url: '/home',
+ iosIcon: homeOutline,
+ mdIcon: homeSharp
+ },
+ {
+ title: `Bookmarks (${ saved.length })`,
+ url: '/saved',
+ iosIcon: bookmarkOutline,
+ mdIcon: bookmarkSharp
+ }
+ ];
+
+ return (
+
+
+
+ Ionic Quotes
+ hey there!
+ {appPages.map((appPage, index) => {
+ return (
+
+
+
+ {appPage.title}
+
+
+ );
+ })}
+
+
+
+ );
+};
+
+export default Menu;
diff --git a/03_source/mobile/src/pages/DemoReactQuotes/components/QuoteItem.module.css b/03_source/mobile/src/pages/DemoReactQuotes/components/QuoteItem.module.css
new file mode 100644
index 0000000..8baadf6
--- /dev/null
+++ b/03_source/mobile/src/pages/DemoReactQuotes/components/QuoteItem.module.css
@@ -0,0 +1,21 @@
+.quoteItem {
+
+ --quote-item-background: rgb(49, 117, 226);
+
+ border: 2px solid rgb(154, 204, 245);
+ border-radius: 10px;
+ --background: var(--quote-item-background);
+ background: var(--quote-item-background);
+ color: white;
+ padding: 1rem;
+}
+
+.quoteText p {
+
+ color: rgb(25, 51, 93);
+}
+
+.quoteText h1:hover {
+
+ color: white;
+}
\ No newline at end of file
diff --git a/03_source/mobile/src/pages/DemoReactQuotes/components/QuoteItem.tsx b/03_source/mobile/src/pages/DemoReactQuotes/components/QuoteItem.tsx
new file mode 100644
index 0000000..85c516f
--- /dev/null
+++ b/03_source/mobile/src/pages/DemoReactQuotes/components/QuoteItem.tsx
@@ -0,0 +1,17 @@
+import { IonCol, IonItem, IonLabel } from "@ionic/react";
+import styles from "./QuoteItem.module.css";
+
+export const QuoteItem = ({ quote }) => {
+
+ return (
+
+
+
+
+ { quote.text }
+ { quote.author }
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/03_source/mobile/src/pages/DemoReactQuotes/pages/Home.tsx b/03_source/mobile/src/pages/DemoReactQuotes/pages/Home.tsx
new file mode 100644
index 0000000..49d05df
--- /dev/null
+++ b/03_source/mobile/src/pages/DemoReactQuotes/pages/Home.tsx
@@ -0,0 +1,62 @@
+import { IonButtons, IonContent, IonGrid, IonHeader, IonInfiniteScroll, IonInfiniteScrollContent, IonList, IonMenuButton, IonPage, IonRow, IonTitle, IonToolbar } from '@ionic/react';
+import { useStoreState } from 'pullstate';
+import { useState } from 'react';
+import { QuoteItem } from '../components/QuoteItem';
+import { QuoteStore } from '../store';
+import { getQuotes } from '../store/Selectors';
+
+const Home = () => {
+
+ const quotes = useStoreState(QuoteStore, getQuotes);
+ const [ amountLoaded, setAmountLoaded ] = useState(20);
+
+ const fetchMore = async e => {
+
+ setAmountLoaded(amountLoaded => amountLoaded + 20);
+ e.target.complete();
+ }
+
+ return (
+
+
+
+
+
+
+ Home
+
+
+
+
+
+
+ Home
+
+
+
+
+
+
+ { quotes.map((quote, index) => {
+
+ if ((index <= amountLoaded) && quote.author) {
+ return (
+
+
+ );
+ } else return "";
+ })}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Home;
\ No newline at end of file
diff --git a/03_source/mobile/src/pages/DemoReactQuotes/pages/Quote.tsx b/03_source/mobile/src/pages/DemoReactQuotes/pages/Quote.tsx
new file mode 100644
index 0000000..4efb394
--- /dev/null
+++ b/03_source/mobile/src/pages/DemoReactQuotes/pages/Quote.tsx
@@ -0,0 +1,88 @@
+import { IonBackButton, IonButton, IonButtons, IonCard, IonCardContent, IonCol, IonContent, IonHeader, IonIcon, IonImg, IonPage, IonRow, IonTitle, IonToolbar, useIonToast } from '@ionic/react';
+import { bookmarkOutline, checkmarkOutline, copyOutline } from 'ionicons/icons';
+import { useStoreState } from 'pullstate';
+import { useEffect, useState } from 'react';
+import { useParams } from 'react-router';
+import { QuoteStore } from '../store';
+import { addSavedQuote, removeSavedQuote } from '../store/QuoteStore';
+import { getQuote, getSavedQuotes } from '../store/Selectors';
+
+import { Clipboard } from '@capacitor/clipboard';
+
+const Quote = () => {
+
+ const { id } = useParams();
+ const quote = useStoreState(QuoteStore, getQuote(id));
+ const saved = useStoreState(QuoteStore, getSavedQuotes);
+ const [ bookmarked, setBookmarked ] = useState(false);
+
+ const [ present ] = useIonToast();
+
+ useEffect(() => {
+
+ setBookmarked(saved.includes(parseInt(id)));
+ }, [ saved, id ]);
+
+ const copyQuote = async () => {
+
+ await Clipboard.write({
+
+ string: quote.text
+ });
+
+ present({
+
+ header: "Success",
+ message: "Quote copied to clipboard!",
+ duration: 2500,
+ color: "primary"
+ });
+ }
+
+ return (
+
+
+
+
+
+
+ Quote
+
+
+
+
+
+
+ Quote
+
+
+
+
+
+
+ { quote.text }
+ - { quote.author }
+
+
+
+
+ bookmarked ? removeSavedQuote(quote.id) : addSavedQuote(quote.id) }>
+
+ { bookmarked ? "Bookmarked" : "Save as Bookmark" }
+
+
+
+
+
+
+ Copy Quote
+
+
+
+
+
+
+ );
+};
+
+export default Quote;
diff --git a/03_source/mobile/src/pages/DemoReactQuotes/pages/Saved.tsx b/03_source/mobile/src/pages/DemoReactQuotes/pages/Saved.tsx
new file mode 100644
index 0000000..171c2a8
--- /dev/null
+++ b/03_source/mobile/src/pages/DemoReactQuotes/pages/Saved.tsx
@@ -0,0 +1,74 @@
+import { IonButtons, IonCol, IonContent, IonGrid, IonHeader, IonInfiniteScroll, IonInfiniteScrollContent, IonList, IonMenuButton, IonPage, IonRow, IonTitle, IonToolbar } from '@ionic/react';
+import { useStoreState } from 'pullstate';
+import { useState } from 'react';
+import { QuoteItem } from '../components/QuoteItem';
+import { QuoteStore } from '../store';
+import { getQuotes, getSavedQuotes } from '../store/Selectors';
+
+const Saved = () => {
+
+ const quotes = useStoreState(QuoteStore, getQuotes);
+ const saved = useStoreState(QuoteStore, getSavedQuotes);
+ const [ amountLoaded, setAmountLoaded ] = useState(20);
+
+ const fetchMore = async e => {
+
+ setAmountLoaded(amountLoaded => amountLoaded + 20);
+ e.target.complete();
+ }
+
+ return (
+
+
+
+
+
+
+ Bookmarks
+
+
+
+
+
+
+ Bookmarks
+
+
+
+
+ { quotes.length > 0 &&
+
+
+
+ { quotes.map((quote, index) => {
+
+ if ((index <= amountLoaded) && saved.includes(parseInt(quote.id))) {
+ return (
+
+
+ );
+ } else return "";
+ })}
+
+
+
+
+
+
+
+ }
+
+ { quotes.length < 1 &&
+
+
+ You haven't saved any bookmarks yet.
+
+
+ }
+
+
+
+ );
+};
+
+export default Saved;
\ No newline at end of file
diff --git a/03_source/mobile/src/pages/DemoReactQuotes/store/QuoteStore.tsx b/03_source/mobile/src/pages/DemoReactQuotes/store/QuoteStore.tsx
new file mode 100644
index 0000000..6740d86
--- /dev/null
+++ b/03_source/mobile/src/pages/DemoReactQuotes/store/QuoteStore.tsx
@@ -0,0 +1,33 @@
+import { Store } from "pullstate";
+
+const QuoteStore = new Store({
+
+ quotes: [],
+ saved: []
+});
+
+export default QuoteStore;
+
+export const addSavedQuote = id => {
+
+ QuoteStore.update(s => { s.saved = [ ...s.saved, id ] });
+}
+
+export const removeSavedQuote = id => {
+
+ QuoteStore.update(s => { s.saved = s.saved.filter(savedId => parseInt(savedId) !== parseInt(id)) });
+}
+
+export const fetchQuotes = async () => {
+
+ const response = await fetch("https://type.fit/api/quotes");
+ const data = await response.json();
+
+ await data.filter((quote, index) => {
+
+ quote.id = (Date.now() + index);
+ quote.image = `https://source.unsplash.com/random/1200x400?sig=${ quote.id }`;
+ });
+
+ QuoteStore.update(s => { s.quotes = data });
+}
\ No newline at end of file
diff --git a/03_source/mobile/src/pages/DemoReactQuotes/store/Selectors.tsx b/03_source/mobile/src/pages/DemoReactQuotes/store/Selectors.tsx
new file mode 100644
index 0000000..a86119c
--- /dev/null
+++ b/03_source/mobile/src/pages/DemoReactQuotes/store/Selectors.tsx
@@ -0,0 +1,10 @@
+import { createSelector } from 'reselect';
+
+const getState = state => state;
+
+// General getters
+export const getQuotes = createSelector(getState, state => state.quotes);
+export const getSavedQuotes = createSelector(getState, state => state.saved);
+
+// Specific getters
+export const getQuote = id => createSelector(getState, state => state.quotes.filter(q => parseInt(q.id) === parseInt(id))[0]);
\ No newline at end of file
diff --git a/03_source/mobile/src/pages/DemoReactQuotes/store/index.tsx b/03_source/mobile/src/pages/DemoReactQuotes/store/index.tsx
new file mode 100644
index 0000000..573f0a4
--- /dev/null
+++ b/03_source/mobile/src/pages/DemoReactQuotes/store/index.tsx
@@ -0,0 +1 @@
+export { default as QuoteStore } from "./QuoteStore";
diff --git a/03_source/mobile/src/pages/DemoReactQuotes/theme/variables.css b/03_source/mobile/src/pages/DemoReactQuotes/theme/variables.css
new file mode 100644
index 0000000..088e83c
--- /dev/null
+++ b/03_source/mobile/src/pages/DemoReactQuotes/theme/variables.css
@@ -0,0 +1,77 @@
+/* Ionic Variables and Theming. For more info, please see:
+http://ionicframework.com/docs/theming/ */
+
+/** Ionic CSS Variables **/
+:root {
+ /** primary **/
+ --ion-color-primary: #3880ff;
+ --ion-color-primary-rgb: 56, 128, 255;
+ --ion-color-primary-contrast: #ffffff;
+ --ion-color-primary-contrast-rgb: 255, 255, 255;
+ --ion-color-primary-shade: #3171e0;
+ --ion-color-primary-tint: #4c8dff;
+
+ /** secondary **/
+ --ion-color-secondary: #3dc2ff;
+ --ion-color-secondary-rgb: 61, 194, 255;
+ --ion-color-secondary-contrast: #ffffff;
+ --ion-color-secondary-contrast-rgb: 255, 255, 255;
+ --ion-color-secondary-shade: #36abe0;
+ --ion-color-secondary-tint: #50c8ff;
+
+ /** tertiary **/
+ --ion-color-tertiary: #5260ff;
+ --ion-color-tertiary-rgb: 82, 96, 255;
+ --ion-color-tertiary-contrast: #ffffff;
+ --ion-color-tertiary-contrast-rgb: 255, 255, 255;
+ --ion-color-tertiary-shade: #4854e0;
+ --ion-color-tertiary-tint: #6370ff;
+
+ /** success **/
+ --ion-color-success: #2dd36f;
+ --ion-color-success-rgb: 45, 211, 111;
+ --ion-color-success-contrast: #ffffff;
+ --ion-color-success-contrast-rgb: 255, 255, 255;
+ --ion-color-success-shade: #28ba62;
+ --ion-color-success-tint: #42d77d;
+
+ /** warning **/
+ --ion-color-warning: #ffc409;
+ --ion-color-warning-rgb: 255, 196, 9;
+ --ion-color-warning-contrast: #000000;
+ --ion-color-warning-contrast-rgb: 0, 0, 0;
+ --ion-color-warning-shade: #e0ac08;
+ --ion-color-warning-tint: #ffca22;
+
+ /** danger **/
+ --ion-color-danger: #eb445a;
+ --ion-color-danger-rgb: 235, 68, 90;
+ --ion-color-danger-contrast: #ffffff;
+ --ion-color-danger-contrast-rgb: 255, 255, 255;
+ --ion-color-danger-shade: #cf3c4f;
+ --ion-color-danger-tint: #ed576b;
+
+ /** dark **/
+ --ion-color-dark: #222428;
+ --ion-color-dark-rgb: 34, 36, 40;
+ --ion-color-dark-contrast: #ffffff;
+ --ion-color-dark-contrast-rgb: 255, 255, 255;
+ --ion-color-dark-shade: #1e2023;
+ --ion-color-dark-tint: #383a3e;
+
+ /** medium **/
+ --ion-color-medium: #92949c;
+ --ion-color-medium-rgb: 146, 148, 156;
+ --ion-color-medium-contrast: #ffffff;
+ --ion-color-medium-contrast-rgb: 255, 255, 255;
+ --ion-color-medium-shade: #808289;
+ --ion-color-medium-tint: #9d9fa6;
+
+ /** light **/
+ --ion-color-light: #f4f5f8;
+ --ion-color-light-rgb: 244, 245, 248;
+ --ion-color-light-contrast: #000000;
+ --ion-color-light-contrast-rgb: 0, 0, 0;
+ --ion-color-light-shade: #d7d8da;
+ --ion-color-light-tint: #f5f6f9;
+}
\ No newline at end of file