diff --git a/.babelrc b/.babelrc index 4292bd0..1bbd4ca 100644 --- a/.babelrc +++ b/.babelrc @@ -1,7 +1,4 @@ { - "presets": ["next/babel", "@emotion/babel-preset-css-prop"], - "plugins": [ - ["emotion"], - ["@babel/plugin-proposal-decorators", { "legacy": true }] - ] + "presets": ["next/babel"], + "plugins": [["@babel/plugin-proposal-decorators", { "legacy": true }]] } diff --git a/.eslintrc.js b/.eslintrc.js index a962406..1e19583 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,6 +22,7 @@ module.exports = { 2, { extensions: ['.js', '.jsx', '.ts', '.tsx'] } ], + 'react/button-has-type': 'off', 'react/react-in-jsx-scope': 'off', 'react/prop-types': 0, 'import/no-extraneous-dependencies': [ @@ -44,7 +45,8 @@ module.exports = { 'always', { exceptAfterSingleLine: true } ], - 'no-underscore-dangle': 'off' + 'no-underscore-dangle': 'off', + 'import/no-cycle': 'off' }, globals: { React: 'writable' diff --git a/.gitignore b/.gitignore index 922d92a..47c1cff 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +#tailwind +/styles/index.css \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..12a141a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,26 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +.env* +yarn.lock + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index b202e36..ae30b02 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,8 @@ { "recommendations": [ "esbenp.prettier-vscode", - "muhajirframe.vscode-react-emotion", "dbaeumer.vscode-eslint", + "bradlc.vscode-tailwindcss", "msjsdiag.debugger-for-chrome" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index c75ad3c..b32ac8f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,5 +9,6 @@ "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, - "editor.formatOnSave": true + // "editor.formatOnSave": true, + "editor.tabSize": 2 } diff --git a/README.md b/README.md index f1ce20d..5bf58de 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Start the project -Make user you have [VS Code](https://code.visualstudio.com/), [Node.js](https://nodejs.org/en/), [Yarn](https://yarnpkg.com/lang/en/), [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint), [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode), and [VScode React Emotion Snippets](https://marketplace.visualstudio.com/items?itemName=muhajirframe.vscode-react-emotion) installed before starting the project. +Make sure you have [VS Code](https://code.visualstudio.com/), [Node.js](https://nodejs.org/en/), [Yarn](https://yarnpkg.com/lang/en/), [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint), [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode), and [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) installed before starting the project. ## Instruction @@ -27,3 +27,11 @@ Install [Debugger for Microsoft Edge](https://marketplace.visualstudio.com/items > Edge: full on VS Code debug panel + +# Running Cypress tests + +Make sure your dev server is open. + +``` +yarn cypress open +``` diff --git a/commons/components/AuthModal.tsx b/commons/components/AuthModal.tsx new file mode 100644 index 0000000..a5d7f53 --- /dev/null +++ b/commons/components/AuthModal.tsx @@ -0,0 +1,131 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { observer } from 'mobx-react-lite'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { useAuthenticationController } from '../../components/authentication'; +import { RootStore } from '../../interfaces/Commons'; +import rootContext from '../context.root'; +import { getEnvName } from '../firebase'; +import Button from './Button'; +import Card from './Card'; +import Modal from './Modal'; +import TextSpinner from './TextSpinner'; + +const AuthModal: React.FC = observer(() => { + const [loginError, setLoginError] = useState(null); + const [activeSignInProcesses, setActiveSignInProcesses] = useState(0); + const { authModalStore } = useContext(rootContext); + const authenticationController = useAuthenticationController(); + + const login = useCallback(async e => { + e.preventDefault(); + setActiveSignInProcesses(x => x + 1); + try { + await authenticationController.loginWithEventpop(); + } catch (error) { + setLoginError(`Failed! ${error}`); + } finally { + setActiveSignInProcesses(x => x - 1); + } + }, []); + + const login2 = useCallback(async e => { + e.preventDefault(); + setActiveSignInProcesses(x => x + 1); + try { + // eslint-disable-next-line no-alert + const referenceCode = prompt('Ticket reference code (6 digits code)'); + if (!referenceCode) { + throw new Error('No reference code provided'); + } + // eslint-disable-next-line no-alert + const phoneNumber = prompt('Your phone number registered with Eventpop'); + if (!phoneNumber) { + throw new Error('No phone number provided'); + } + await authenticationController.loginWithEventpopInfo( + referenceCode, + phoneNumber + ); + } catch (error) { + setLoginError(`Failed! ${error}`); + } finally { + setActiveSignInProcesses(x => x - 1); + } + }, []); + + const loginTest = useCallback(async (e, uid: string) => { + e.preventDefault(); + setActiveSignInProcesses(x => x + 1); + try { + await authenticationController.loginAsTestUser(uid); + } catch (error) { + setLoginError(`Failed! ${error}`); + } finally { + setActiveSignInProcesses(x => x - 1); + } + }, []); + + const [isTestEnvironment, setIsTestEnvironment] = useState(false); + + useEffect(() => { + setIsTestEnvironment(getEnvName() === 'test'); + }, []); + + const testUsers = isTestEnvironment + ? ['test01', 'test02', 'test03', 'test04', 'test05'] + : []; + + return ( + +
+ +

+ To continue, Please log in +

+

+ Make your meal selection and meet other people through our + networking activity to win special prizes. +

+ + - or - + + {testUsers.map(uid => { + return ( + + ); + })} + {loginError} +
+
+
+ ); +}); + +export default AuthModal; diff --git a/commons/components/Button.tsx b/commons/components/Button.tsx new file mode 100644 index 0000000..6b463a1 --- /dev/null +++ b/commons/components/Button.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Onclick } from '../../interfaces/Commons'; + +interface Props { + className?: string; + children?: React.ReactNode; + type: 'submit' | 'button' | 'reset'; + onClick?: Onclick; + disabled?: boolean; + 'aria-label'?: string; +} + +const Button: React.FC = ({ + children, + className, + type, + onClick, + disabled, + 'aria-label': ariaLabel +}) => { + return ( + + ); +}; + +export default Button; diff --git a/commons/components/Card.tsx b/commons/components/Card.tsx new file mode 100644 index 0000000..d8ce711 --- /dev/null +++ b/commons/components/Card.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +interface PropTypes { + className?: string; + noPadding?: boolean; + 'aria-labelledby'?: string; + 'aria-label'?: string; + 'data-testid'?: string; + tabIndex?: number; +} + +const Card: React.FC = ({ + children, + className, + noPadding, + tabIndex, + 'aria-labelledby': ariaLabelledby, + 'aria-label': ariaLabel, + 'data-testid': dataTestid +}) => { + return ( +
+ {children} +
+ ); +}; + +export default Card; diff --git a/commons/components/CloseButton.tsx b/commons/components/CloseButton.tsx new file mode 100644 index 0000000..f701736 --- /dev/null +++ b/commons/components/CloseButton.tsx @@ -0,0 +1,11 @@ +import React, { MouseEvent } from 'react'; + +interface PropTypes { + onClick: (e: MouseEvent) => void; +} + +const CloseButton: React.FC = ({ onClick }) => { + return ; +}; + +export default CloseButton; diff --git a/commons/components/ErrorMessage.tsx b/commons/components/ErrorMessage.tsx new file mode 100644 index 0000000..28e450f --- /dev/null +++ b/commons/components/ErrorMessage.tsx @@ -0,0 +1,13 @@ +export default function ErrorMessage(props: { error?: any }) { + const { error } = props; + return ( +
+ Something went wrong! +
+ {String(error)} +
+ ); +} diff --git a/commons/components/Loading.tsx b/commons/components/Loading.tsx new file mode 100644 index 0000000..dc8cc29 --- /dev/null +++ b/commons/components/Loading.tsx @@ -0,0 +1,16 @@ +const Loading: React.FC<{ message: string; color?: 'dark' | 'light' }> = ({ + message, + color = 'dark' +}) => { + return ( +
+ {message}… +
+ ); +}; + +export default Loading; diff --git a/commons/components/Modal.tsx b/commons/components/Modal.tsx new file mode 100644 index 0000000..8fdb76c --- /dev/null +++ b/commons/components/Modal.tsx @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/indent */ +import React, { useCallback, useMemo } from 'react'; +import { observer } from 'mobx-react-lite'; +import FocusLock from 'react-focus-lock'; +import Card from './Card'; +import { ModalStore } from '../stores/authModalStores'; + +interface PropTypes { + modalStore: ModalStore; + noCloseButton?: boolean; + className?: string; + 'aria-label'?: string; + 'data-testid'?: string; +} + +const Modal: React.FC = observer( + ({ + children, + modalStore, + noCloseButton = false, + className, + 'aria-label': ariaLabel, + 'data-testid': dataTestid + }) => { + const closeModal = useCallback(() => { + modalStore.setModalOpen(false); + }, []); + + const content = useMemo(() => { + return ( + + {children} + {!noCloseButton && ( + + )} + + ); + }, [noCloseButton, children]); + + const { isAnimating, isHidden, isModalOpen } = modalStore; + + const ANIMATION_CLASSES = noCloseButton + ? `${isAnimating ? 'fade-out' : ''} ${isHidden ? 'hidden' : ''}` + : `${isAnimating && !isModalOpen ? 'opacity-0' : ''} ${ + isHidden ? 'invisible opacity-0' : '' + } fade`; + const MODAL_CLASSES = `fixed top-0 left-0 w-full h-screen overflow-y-auto ${ANIMATION_CLASSES} ${className}`; + + return ( + +
{content}
+
+ ); + } +); + +export default Modal; diff --git a/commons/components/MyLink.tsx b/commons/components/MyLink.tsx new file mode 100644 index 0000000..50c39da --- /dev/null +++ b/commons/components/MyLink.tsx @@ -0,0 +1,37 @@ +import { useRouter } from 'next/router'; +import React, { useEffect, MouseEvent } from 'react'; + +interface PropTypes { + href: string; + prefetch?: boolean; + className?: string; + onClick?: (e: MouseEvent) => void; +} + +const MyLink: React.FC = ({ + href, + className, + children, + onClick, + prefetch = true +}) => { + const router = useRouter(); + + useEffect(() => { + if (prefetch) router.prefetch(href); + }); + + const handleClick = (e: MouseEvent) => { + e.preventDefault(); + if (onClick) onClick(e); + router.push(href); + }; + + return ( + + {children} + + ); +}; + +export default MyLink; diff --git a/commons/components/Nav.tsx b/commons/components/Nav.tsx new file mode 100644 index 0000000..cd20196 --- /dev/null +++ b/commons/components/Nav.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import MyLink from './MyLink'; + +const Nav: React.FC<{}> = () => { + return ( + + ); +}; + +export default Nav; diff --git a/commons/components/PageHeading.tsx b/commons/components/PageHeading.tsx new file mode 100644 index 0000000..198b2b9 --- /dev/null +++ b/commons/components/PageHeading.tsx @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/indent */ +/* eslint-disable no-nested-ternary */ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { + RouteData, + isFetchingFailed, + isFetchingCompleted +} from '../../interfaces/Commons'; +import Button from './Button'; +import { + useAuthenticationState, + useAuthenticationController, + isAuthenticated, + ProfileData +} from '../../components/authentication'; + +interface PropTypes { + routeData: RouteData; +} + +const PageHeading: React.FC = observer(({ routeData }) => { + const authenticationState = useAuthenticationState(); + const authenticationController = useAuthenticationController(); + const authenticationStateDescription = isFetchingFailed(authenticationState) + ? 'checking-failed' + : !isFetchingCompleted(authenticationState) + ? 'checking' + : isAuthenticated(authenticationState) + ? 'authenticated' + : 'unauthenticated'; + const getName = (profile: ProfileData) => + `${profile.firstname} ${profile.lastname}`; + return ( +
+
{routeData.title}
+
+ {isAuthenticated(authenticationState) && ( + + )} +
+
+ ); +}); + +export default PageHeading; diff --git a/commons/components/Tag.tsx b/commons/components/Tag.tsx new file mode 100644 index 0000000..0a846dd --- /dev/null +++ b/commons/components/Tag.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const Tag: React.FC<{ title: string }> = ({ title }) => { + return
{title}
; +}; + +export default Tag; diff --git a/commons/components/TextSpinner.tsx b/commons/components/TextSpinner.tsx new file mode 100644 index 0000000..5d15b37 --- /dev/null +++ b/commons/components/TextSpinner.tsx @@ -0,0 +1,18 @@ +import { useState, useEffect } from 'react'; + +const template = '-\\|/'; + +const TextSpinner: React.FC<{}> = () => { + const [frame, setFrame] = useState(0); + useEffect(() => { + const interval = setInterval(() => { + setFrame(f => f + 1); + }, 150); + return () => { + clearInterval(interval); + }; + }, []); + return {template[frame % template.length]}; +}; + +export default TextSpinner; diff --git a/commons/context.root.tsx b/commons/context.root.tsx new file mode 100644 index 0000000..dbf167e --- /dev/null +++ b/commons/context.root.tsx @@ -0,0 +1,9 @@ +import { createContext } from 'react'; +import { RootStore } from '../interfaces/Commons'; + +const rootContext = createContext({ + userStore: {}, + authModalStore: {} +} as RootStore); + +export default rootContext; diff --git a/commons/firebase/firebase.ts b/commons/firebase/firebase.ts new file mode 100644 index 0000000..ffc49ad --- /dev/null +++ b/commons/firebase/firebase.ts @@ -0,0 +1,38 @@ +import firebase from 'firebase/app'; + +/* eslint import/no-duplicates: off */ +import 'firebase/auth'; +import 'firebase/database'; +import 'firebase/firestore'; +import 'firebase/functions'; + +import getEnvName from './getEnvName'; + +const firebaseConfig = { + apiKey: 'AIzaSyCDBqjN2IOo-sycp9ITPgSNpc_KBPtjTYg', + authDomain: 'javascriptbangkok-companion.firebaseapp.com', + databaseURL: 'https://javascriptbangkok-companion.firebaseio.com', + projectId: 'javascriptbangkok-companion', + storageBucket: 'javascriptbangkok-companion.appspot.com', + messagingSenderId: '838146383473', + appId: '1:838146383473:web:91601ee661d34794a085d0', + measurementId: 'G-FPZZTTK06Y' +}; + +firebase.initializeApp(firebaseConfig); + +export const { auth, database, firestore } = firebase; +export const functions = (region: string) => firebase.app().functions(region); + +export function getEnvRef() { + const env = getEnvName(); + return firebase.database().ref(`environments/${env}`); +} + +export function getEnvDoc() { + const env = getEnvName(); + return firebase + .firestore() + .collection('environments') + .doc(env); +} diff --git a/commons/firebase/getEnvName.ts b/commons/firebase/getEnvName.ts new file mode 100644 index 0000000..dde2786 --- /dev/null +++ b/commons/firebase/getEnvName.ts @@ -0,0 +1,9 @@ +export default function getEnvName() { + const match = String(window.location.search).match(/[&?]env=(\w+)/); + if (match) { + const [, env] = match; + sessionStorage.JS_BANGKOK_BACKEND_ENV = env; + return env; + } + return sessionStorage.JS_BANGKOK_BACKEND_ENV || 'production'; +} diff --git a/commons/firebase/index.ts b/commons/firebase/index.ts new file mode 100644 index 0000000..485d601 --- /dev/null +++ b/commons/firebase/index.ts @@ -0,0 +1,93 @@ +import { useState, useEffect } from 'react'; +import getEnvName from './getEnvName'; +import useFetcher from '../hooks/useFetcher'; +import { + FetchResult, + isFetchingCompleted, + isFetchingFailed +} from '../../interfaces/Commons'; + +export async function getFirebase() { + return import(/* webpackChunkName: "firebase" */ './firebase'); +} + +export { getEnvName }; + +export type FirebaseModule = typeof import('../../commons/firebase/firebase'); +export type User = import('firebase').User; +export type FirestoreReference = import('firebase').firestore.DocumentReference; +export type FirestoreSnapshot = import('firebase').firestore.DocumentSnapshot; +export type RealtimeDatabaseReference = import('firebase').database.Reference; +export type RealtimeDatabaseSnapshot = import('firebase').database.DataSnapshot; + +export function useFirebase() { + return useFetcher(getFirebase); +} + +export function useFirestoreSnapshot( + getRef: (firebase: FirebaseModule) => FirestoreReference +): FetchResult { + const firebaseFetchResult = useFirebase(); + + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isFetchingCompleted(firebaseFetchResult)) { + return () => {}; + } + const firebase = firebaseFetchResult.data; + return getRef(firebase).onSnapshot(setSnapshot, setError); + }, [firebaseFetchResult, getRef]); + + if (isFetchingFailed(firebaseFetchResult)) { + return { status: 'error', error: firebaseFetchResult.error }; + } + if (!isFetchingCompleted(firebaseFetchResult)) { + return { status: 'loading' }; + } + if (error) { + return { status: 'error', error }; + } + if (!snapshot) { + return { status: 'loading' }; + } + return { status: 'completed', data: snapshot }; +} + +export function useRealtimeDatabaseSnapshot( + getRef: (firebase: FirebaseModule) => RealtimeDatabaseReference +): FetchResult { + const firebaseFetchResult = useFirebase(); + + const [snapshot, setSnapshot] = useState( + null + ); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isFetchingCompleted(firebaseFetchResult)) { + return () => {}; + } + const firebase = firebaseFetchResult.data; + const ref = getRef(firebase); + ref.on('value', setSnapshot, setError); + return () => { + ref.off('value', setSnapshot); + }; + }, [firebaseFetchResult, getRef]); + + if (isFetchingFailed(firebaseFetchResult)) { + return { status: 'error', error: firebaseFetchResult.error }; + } + if (!isFetchingCompleted(firebaseFetchResult)) { + return { status: 'loading' }; + } + if (error) { + return { status: 'error', error }; + } + if (!snapshot) { + return { status: 'loading' }; + } + return { status: 'completed', data: snapshot }; +} diff --git a/commons/globals/index.ts b/commons/globals/index.ts new file mode 100644 index 0000000..12fb653 --- /dev/null +++ b/commons/globals/index.ts @@ -0,0 +1,12 @@ +const JSBangkokApp = { + testCommands: {} as { [key: string]: Function } +}; + +if (typeof window !== 'undefined') { + Object.assign(window, { JSBangkokApp }); +} + +// eslint-disable-next-line import/prefer-default-export +export function registerTestCommand(commandName: string, fn: Function) { + JSBangkokApp.testCommands[commandName] = fn; +} diff --git a/commons/hooks/networkingHooks.ts b/commons/hooks/networkingHooks.ts new file mode 100644 index 0000000..a8b9d9b --- /dev/null +++ b/commons/hooks/networkingHooks.ts @@ -0,0 +1,124 @@ +/* eslint-disable @typescript-eslint/indent */ +import { useCallback, useEffect, useState } from 'react'; +import { + getEnvName, + getFirebase, + FirebaseModule, + useFirestoreSnapshot, + useRealtimeDatabaseSnapshot +} from '../firebase'; +import { NetworkingProfile } from '../../interfaces/Users'; +import { + Networking, + isFetchingFailed, + isFetchingCompleted +} from '../../interfaces/Commons'; + +export default async function addUserToNetwork(uid: string) { + const firebase = await getFirebase(); + const _addUserToNetwork = firebase + .functions('asia-northeast1') + .httpsCallable('addUserToNetwork'); + await _addUserToNetwork({ + uid, + env: getEnvName() + }); +} + +export async function createNetworkingProfile(bio: string) { + const firebase = await getFirebase(); + const _createNetworkingProfile = firebase + .functions('asia-northeast1') + .httpsCallable('createNetworkingProfile'); + await _createNetworkingProfile({ + uid: firebase.auth().currentUser!.uid, + env: getEnvName(), + bio + }); +} + +export async function getNetworkingProfile(uid: string) { + const firebase = await getFirebase(); + const _getNetworkingProfile = firebase + .functions('asia-northeast1') + .httpsCallable('getNetworkingProfile'); + return _getNetworkingProfile({ + uid, + env: getEnvName() + }); +} + +export async function updateBio(bio: string) { + const firebase = await getFirebase(); + const _createNetworkingProfile = firebase + .functions('asia-northeast1') + .httpsCallable('updateBio'); + await _createNetworkingProfile({ + bio, + env: getEnvName() + }); +} + +export const useNetworking = (): Networking => { + const [uuid, setUuid] = useState(); + + const getDocument = useCallback( + (firebase: FirebaseModule) => + firebase + .getEnvDoc() + .collection('networkingProfiles') + .doc(firebase.auth().currentUser!.uid), + [] + ); + + const getWinner = useCallback( + (firebase: FirebaseModule) => + firebase + .getEnvRef() + .child('networking') + .child('winners'), + [] + ); + + const snapshotFetchResult = useFirestoreSnapshot(getDocument); + const realtimeFetchResult = useRealtimeDatabaseSnapshot(getWinner); + + useEffect(() => { + getFirebase().then(firebase => { + setUuid(firebase.auth().currentUser!.uid); + }); + }, []); + + const winners = realtimeFetchResult.data?.val(); + const winnersArray = winners ? Object.entries(winners) : []; + const hasAllWinner = winnersArray.length >= 3; + const isWinner = + hasAllWinner && + winnersArray + .sort((w1, w2) => (w1 as any)[1] - (w2 as any)[1]) + .slice(0, 3) + .filter(winner => { + return winner[0] === uuid; + }).length !== 0; + + if (isFetchingFailed(snapshotFetchResult)) { + return { status: 'error', error: snapshotFetchResult.error }; + } + if (!isFetchingCompleted(snapshotFetchResult)) { + return { status: 'loading' }; + } + + const snapshot = snapshotFetchResult.data; + const result = { + status: 'completed', + data: snapshot.data() as NetworkingProfile, + hasAllWinner, + isWinner, + uuid: snapshot.id + }; + + if (result.status === 'completed' && result.data === undefined) { + return { status: 'notRegistered' }; + } + return result; +}; diff --git a/commons/hooks/scheduleHook.ts b/commons/hooks/scheduleHook.ts new file mode 100644 index 0000000..a86d964 --- /dev/null +++ b/commons/hooks/scheduleHook.ts @@ -0,0 +1,372 @@ +import { compareAsc } from 'date-fns'; +import { useLayoutEffect, useState } from 'react'; +import { Schedule } from '../../interfaces/Schedule'; + +export const schedule: Schedule[] = [ + { + key: 1, + title: '📝Registration', + hours: '08', + minutes: '30', + happening: true, + happened: true + }, + { + key: 2, + title: 'Opening remarks', + hours: '09', + minutes: '00', + happening: true, + happened: false + }, + { + key: 3, + title: 'A journey of building large-scale reusable web components', + speaker: 'Varayut Lerdkanlayanawat', + hours: '09', + minutes: '10', + position: 'Software Development Engineer @ Amazon', + image: 'https://javascriptbangkok.com/speaker-images/05.jpg', + url: 'https://github.com/lvarayut', + email: 'l.varayut@gmail.com', + about: + 'I’m Varayut Lerdkanlayanawat.\nI’m currently working as a Software Development Engineer at Amazon based in Berlin, Germany.\nI\'ve officially been in the web development industry for around 9 years.\nI love to share what I know by giving private training sessions to companies,\nworking on open-source projects on GitHub,\nmaking programming videos on YouTube,\nanswering StackOverflow questions,\nand writing tutorials on Medium.\n', + description: + 'Have you ever wondered what the process of building reusable web components that are used by 200+ developer teams looks like? In this talk, you will be walked through all aspects that need to be considered while designing and implementing reusable web components along with fun real-world examples.\n', + happening: false, + happened: false + }, + { + key: 4, + title: 'Optimization design patterns - from games to web', + speaker: 'Yonatan Kra', + hours: '09', + minutes: '40', + position: 'Staff Engineer @ WalkMe', + image: 'https://javascriptbangkok.com/speaker-images/02.jpg', + url: 'https://bit.ly/yk_blog', + email: 'kra.yonatan@gmail.com', + about: + 'Yonatan has been involved in some awesome projects in the academy and the industry - from C/C++ through Matlab to PHP and javascript. Former CTO at Webiks. Currently he is a Software Architect at WalkMe and an egghead instructor.\n', + description: + 'Gamers expect a flawless real-like experience. So do your applications users. Utilizing techniques that are heavily used in games, can help you boost your app’s performance and also save you money in cloud expanses. We’ll see how you can save on CPU, memory and bandwidth with these techniques.\n', + happening: false, + happened: false + }, + { + key: 5, + title: 'The Art of Crafting Codemods', + speaker: 'Rajasegar Chandran', + hours: '10', + minutes: '10', + position: 'Front-end Developer @ Freshworks Inc.', + image: 'https://javascriptbangkok.com/speaker-images/04.jpg', + url: 'http://hangaroundtheweb.com/', + email: 'rajasegar.c@gmail.com', + about: + 'Rajasegar works with Freshworks as a front-end developer. He is passionate about open-source software and currently writes codemods for the Ember community.\n', + description: + 'Codemod is a mechanism to make sweeping changes across your code with ease and effectiveness, assisting in large-scale migrations of the code-base. This can be performed through automated tools such as jscodeshift.\n', + happening: false, + happened: false + }, + { + key: 6, + title: 'Our lovely sponsor: KBTG', + speaker: 'Varayut Lerdkanlayanawat', + hours: '10', + minutes: '50', + happening: false, + happened: false + }, + + { + key: 7, + title: 'What happens when you cancel an HTTP request?', + speaker: 'Younes Jaaidi', + hours: '11', + minutes: '20', + position: 'Developer & eXtreme Programming Coach @ Marmicode Wishtack', + image: 'https://javascriptbangkok.com/speaker-images/08.jpg', + url: 'https://marmicode.io', + email: 'yjaaidi@gmail.com', + about: + 'Younes is a Google Developer Expert for Angular & Web Technologies.\nHe is a trainer, consultant & eXtreme Programming coach who loves the challenge of boosting teams efficiency and helping everyone enjoy every part of their job.\nHis experience convinced him that the key to making quality products is collective ownership, kindness and knowledge sharing.\nOn his spare time, you will find him contributing to open-source software, writing articles or speaking at meetups or conferences… and sometimes sailing.\nHis favorite trick? Adding features by removing code.\n', + description: + 'Reactive libraries like RxJS allow us to easily cancel HTTP requests but is that really efficient? What really happens on the back-end? Is it possible to propagate the cancelation from the front-end through our microservices and cancel the database query?\n', + happening: false, + happened: false + }, + { + key: 8, + title: 'How I met my superset of Javascript ', + speaker: 'Sirirat Rungpetcharat', + hours: '11', + minutes: '40', + position: 'CTO @ Builk One Group Co., Ltd.', + image: 'https://javascriptbangkok.com/speaker-images/14.jpg', + url: 'https://medium.com/@coalapaparazzi', + email: 'kra.yonatan@gmail.com', + about: + 'Sirirat Rungpetcharat (Yui) Chief Technology Officer of Builk One Group Co., Ltd.\nWith 10 years experience in technology field. From mere programmer to CTO with Lead UX Designer position. Cat lady w/ 6 cats under my care.\nWhat’s more to sell? ;)\n', + description: + 'My love for TypeScript is what people call “destiny”. With strongly-typed, OOP concept and how familiar we’ve been with Angular, etc. But it’s taken me awhile to hop in since changing technology require heavily researching, convincing both my team and the board. I’m here to tell you how my love life be.\n', + happening: false, + happened: false + }, + + { + key: 9, + title: 'Lunch Break', + hours: '12', + minutes: '25', + happening: false, + happened: false + }, + { + key: 10, + title: + 'Talking about “Scale”: Takeaways from our attempt on scaling a small system in the Gojek Universe', + speaker: 'Tino Thamjarat', + hours: '13', + minutes: '00', + position: 'Product Engineer @ Gojek', + image: 'https://javascriptbangkok.com/speaker-images/07.jpg', + url: 'https://vtno.me/', + email: 'tino@vtno.me', + about: + "Tino is a product engineer at Gojek and currently works in the GoFinance team.\nHe loves building products that solve people's problems from random Starbucks in Bangkok,\ncoaching and telling sort of useful stories at conferences.\nHe also loves playing music!\nHe will be super happy to chat about all sort of software engineering topics especially clean code and DevOps.\nTino loves efficiency on every level.\n", + description: + 'The year is 2019 and every engineer must have been asked once to build a “scalable” system. I will be telling the story of our team journey in building a financial system that serves 20X traffic in less than a year. Engineering practices, wrong (and right!) decisions, process improvement and more!\n', + happening: false, + happened: false + }, + { + key: 11, + title: 'Adventures with the Event Loop', + speaker: 'Erin Zimmer', + hours: '13', + minutes: '40', + position: 'Senior Engineer & Thought Leader @ Shine Solutions', + image: 'https://javascriptbangkok.com/speaker-images/09.jpg', + url: 'https://ez.codes', + email: 'ejzimmer@gmail.com', + about: + 'Erin is a software developer with over ten years experience in a variety of languages,\nfrom JavaScript to Model204 (don’t worry, nobody else has heard of it either).\nShe is currently a senior web developer at Shine Solutions.\nShe is an active member of the Melbourne JavaScript and Angular communities,\nand has spoken at conferences around the world.\nIf you see her at a conference, she’ll probably have knitting needles in hand.\n', + description: + 'The event loop completely underpins everything that happens in the browser. Yet many developers know very little about it. This talk will help them better understand the nitty-gritty of what’s really going on when you create a Promise, add an event listener, or request an animation frame.\n', + happening: false, + happened: false + }, + { + key: 12, + title: 'End-to-end Type-Safe GraphQL Apps', + speaker: 'Carlos Rufo', + hours: '14', + minutes: '10', + position: 'Organizer @ GraphQL Hong Kong', + image: 'https://javascriptbangkok.com/speaker-images/10.jpg', + url: 'https://medium.com/@swcarlosrj', + email: 'info@carlosrj.com', + about: + 'Carlos is a passionate developer and speaker aficionado.\nWhile he codes with different B/FE techs, his go-to for every project is his crush: GraphQL.\nHe is very active in the GraphQL ecosystem where he has collaborated with across numerous internal & external projects,\nsuch as SpaceX GraphQL API and recently, co-organizing GraphQL Hong Kong & GraphQL Shenzhen.\nIn his free time he loves stargazing & rocket science, but mostly, help to build a community where everyone could learn about everything!\n', + description: + 'Discover all the benefits of using GraphQL adding End-to-end Type-Safety to your app with this live-coding talk. At the end of such, you’ll want to refactor your codebase in order to take all the advantages of TypeScript, GraphQL & React working together on a SpaceX demo 🚀\n', + happening: false, + happened: false + }, + { + key: 13, + title: 'Our lovely sponsor: Oozou', + speaker: 'Varayut Lerdkanlayanawat', + hours: '14', + minutes: '40', + happening: false, + happened: false + }, + { + key: 14, + title: 'Applying SOLID principle in JavaScript without Class and Object', + speaker: 'Chakrit Likitkhajorn', + hours: '15', + minutes: '30', + position: 'Senior Software Engineer @ Omise', + image: 'https://javascriptbangkok.com/speaker-images/06.png', + url: 'https://medium.com/@chrisza', + email: 'chakrit.lj@gmail.com', + about: + 'Once VP engineering at Taskworld, now consulting with multiples companies. I am a developer passionate about how to build software as a team and modeling problem inside code.\n', + description: + 'The SOLID principle is well-known in our industry. However, most of the articles, books, and examples are based on traditional Object-oriented language constructs.\nThis talk will show how can we apply these principles in Javascript where classes are not necessary nor encouraged.\n', + happening: false, + happened: false + }, + { + key: 15, + title: 'DevTools, the CSS advocate in your browser', + speaker: 'Chen Hui Jing', + hours: '16', + minutes: '00', + position: 'Developer Advocate @ Nexmo', + image: 'https://javascriptbangkok.com/speaker-images/12.jpg', + url: 'https://www.chenhuijing.com', + email: 'kakyou_tensai@yahoo.com', + about: + 'Chen Hui Jing is a self-taught designer and developer living in Singapore, with an inordinate love for CSS, as evidenced by her blog, that is mostly about CSS, and her tweets, which are largely about typography and the web.\nShe used to play basketball full-time and launched her web career during downtime between training sessions.\nHui Jing is currently a Developer Advocate for Nexmo, focusing on growing developer engagement around the APAC region.\n', + description: + 'New CSS features, like Flexbox, Grid or Shapes, introduce new properties that can sometimes be complicated to people who are encountering them for the first time. This talk will introduce DevTools features that can help us understand what’s going on, and make it less intimidating to try out new CSS.\n', + happening: false, + happened: false + }, + { + key: 16, + title: 'Our lovely sponsor: ODDS', + hours: '16', + minutes: '30', + happening: false, + happened: false + }, + { + key: 17, + title: + "Poor Man's Patcher: A game modder's adventure through serverless sea without money", + speaker: 'Atthaporn Thanongkiatisak', + hours: '16', + minutes: '40', + position: 'Application Architect @ EGG Digital, Ascend Group', + image: 'https://javascriptbangkok.com/speaker-images/13.jpg', + url: 'https://www.atp-tha.com/', + email: 'atp.tha77@gmail.com', + about: + 'Application Architect @ EGG Digital, Ascend Group.\nI cannot stop rambling about Serverless and DevOps.\nMy favorite food is Angular, .NET Core and AWS.\nGame Design enthusiast,\nSelf-proclaimed Music Producer,\nTea > Coffee, still caffeine.\n', + description: + 'Before the words “DevOps” and “Serverless” even become well-known, I, as a hobbyist Game Modder, was trying to achieve these 2 things using JavaScript and a lot of free services for my mod distribution patcher app. In this talk, I’ll walk you through how I did it and what’s my thinking behind.\n', + happening: false, + happened: false + }, + { + key: 18, + title: 'Building your first malicious chrome extension 😈', + speaker: 'Alon Kiriati', + hours: '17', + minutes: '10', + position: 'Tech Lead @ Dropbox', + image: 'https://javascriptbangkok.com/speaker-images/11.jpg', + url: 'https://www.linkedin.com/in/akiriati/', + email: 'akiriati@hotmail.com', + about: + 'A tech lead and a full stack developer at Dropbox.\nDuring the last 15 years I’ve been working with companies from any size and shapes - 3 people start ups, 50 medium companies, and 1,000+ corporates, and learned precious lessons from each position I had.\nI’ve been using with a vast variety of languages and frameworks from the very low level of RT/Embedded and all the way "up" to react.js.\nI’m enthusiastic about culture, tech, product and ping pong.\nI believe everything in the world can be expressed with emojis, and one day they will replace all languages 👻\n', + description: + 'In this talk I will explain the basics of building your first chrome extension, in just a couple of minutes! It takes few more lines to turn it into a malicious one. The main purpose here is not to turn you into a hacker, but to increase awareness to these “small” and “harmless” plugins.\n', + happening: false, + happened: false + }, + { + key: 19, + title: 'Our lovely sponsor: ExxonMobil', + hours: '17', + minutes: '40', + happening: false, + happened: false + }, + { + key: 20, + title: 'Just go for it: The story of dance-mat.js ', + speaker: 'Ramón Huidobro', + hours: '17', + minutes: '50', + position: 'Freelancer', + image: 'https://javascriptbangkok.com/speaker-images/03.jpg', + url: 'https://ramonh.dev', + email: 'hola@ramonh.dev', + about: + 'Freelance software dev, avid community member, kitten herder, kids’ coding instructor.\n', + description: + 'Side projects can be daunting. It takes discipline to get started, and even more so to finish.\nIn this talk, I’ll introduce dance-mat.js, the project for making a Dance Dance Revolution controller with a yoga mat, a Raspberry Pi, conductive paint, and Node.js.\n', + happening: false, + happened: false + }, + { + key: 21, + title: 'Speed up heavy data visualization with Rust and WebAssembly', + speaker: 'Rujira Aksornsin', + hours: '18', + minutes: '20', + position: 'Frontend Developer Team Lead @ AppMan', + image: 'https://javascriptbangkok.com/speaker-images/15.jpg', + url: null, + email: null, + about: null, + description: + 'When we need to perform a large data calculation and a large data visualization on the website, performance issues always came as an old familiar friend. This talk will share my experiment on using Rust and WebAssembly to solve this problem base on old project limitations and conditions.\n', + happening: false, + happened: false + }, + { + key: 22, + title: 'A love story written in JavaScript', + speaker: 'Ramón Guijarro', + hours: '18', + minutes: '50', + position: 'Software Engineer @ Undefined Labs', + image: 'https://javascriptbangkok.com/speaker-images/01.jpg', + url: 'https://speakerdex.co/soyguijarro', + email: 'hola@soyguijarro.com', + about: + 'Ramón is a product-focused web engineer and all-around web lover.\nHe enjoys building user interfaces with JavaScript and React, speaking at conferences, moving fast and learning new things (tech-related or otherwise).\nHe also likes using JavaScript to build stuff beyond the browser and finding out about unexpected uses for the web in general.\nHe cares about community, diversity and inclusion as well.\nMaking up silly personal needs in order to solve them with equally silly code projects is another of his questionable virtues.\n', + description: + 'Dating apps can feel tedious and like a waste of time.\nIs there a way to skip the grunt work?\nThat’s what I asked myself six months ago when I built Swipr, a tool written in JavaScript that does the swiping for you.\nIn this talk we’ll see how it works and how to built CLIs with Node along the way.\n', + happening: false, + happened: false + }, + { + key: 23, + title: 'Closing remarks', + hours: '19', + minutes: '20', + happening: false, + happened: false + }, + { + key: 24, + title: '🎉Networking party', + hours: '19', + minutes: '30', + happening: false, + happened: false + } +]; + +const checkScheduleTime = (result: Schedule[]) => { + for (let i: number = 0; i < result.length - 1; i += 1) { + result[i].happened = + compareAsc( + new Date(), + new Date(`2020-02-08T${result[i].hours}:${result[i].minutes}:00+07:00`) + ) === 1; + result[i].happening = + compareAsc( + new Date(), + new Date(`2020-02-08T${result[i].hours}:${result[i].minutes}:00+07:00`) + ) !== -1 && + compareAsc( + new Date(), + new Date( + `2020-02-08T${result[i + 1].hours}:${result[i + 1].minutes}:00+07:00` + ) + ) === -1; + } + return result; +}; + +const useSchedule = () => { + const [_schedule, setSchedule] = useState(schedule); + + useLayoutEffect(() => { + setSchedule(checkScheduleTime(_schedule)); + }, []); + + return _schedule; +}; + +export default useSchedule; diff --git a/commons/hooks/submitFoodOrder.ts b/commons/hooks/submitFoodOrder.ts new file mode 100644 index 0000000..f71af01 --- /dev/null +++ b/commons/hooks/submitFoodOrder.ts @@ -0,0 +1,16 @@ +import { getFirebase, getEnvName } from '../firebase'; + +export default async function submitFoodOrder( + restaurantId: string, + customizations: { [key: string]: string[] } +) { + const firebase = await getFirebase(); + const selectFoodChoice = firebase + .functions('asia-northeast1') + .httpsCallable('selectFoodChoice'); + await selectFoodChoice({ + restaurantId, + customizations, + env: getEnvName() + }); +} diff --git a/commons/hooks/useApi.ts b/commons/hooks/useApi.ts new file mode 100644 index 0000000..5b148b1 --- /dev/null +++ b/commons/hooks/useApi.ts @@ -0,0 +1,19 @@ +import axios from 'axios'; +import { useMemo } from 'react'; + +let baseURL = 'https://jsonplaceholder.typicode.com'; +if (process.env.NODE_ENV === 'production') { + baseURL = 'PRODUCTION URL'; +} + +const useApi = () => { + const apiService = useMemo( + () => ({ + client: axios.create({ baseURL }) + }), + [] + ); + return apiService; +}; + +export default useApi; diff --git a/commons/hooks/useFetcher.ts b/commons/hooks/useFetcher.ts new file mode 100644 index 0000000..9787d1d --- /dev/null +++ b/commons/hooks/useFetcher.ts @@ -0,0 +1,33 @@ +/* eslint-disable no-shadow */ +import { useEffect, useState } from 'react'; +import { FetchResult } from '../../interfaces/Commons'; + +const useDataPromise = (fetcher: () => Promise) => { + const [state, updateState] = useState( + (): FetchResult => ({ + data: undefined, + error: null, + status: 'loading' + }) + ); + useEffect(() => { + fetcher() + .then(data => { + updateState(state => ({ + ...state, + data, + status: 'completed' + })); + }) + .catch(error => { + updateState(state => ({ + ...state, + error, + status: 'error' + })); + }); + }, []); + return state; +}; + +export default useDataPromise; diff --git a/commons/hooks/useFoodSelection.ts b/commons/hooks/useFoodSelection.ts new file mode 100644 index 0000000..346c4f2 --- /dev/null +++ b/commons/hooks/useFoodSelection.ts @@ -0,0 +1,42 @@ +import { useForm } from 'react-hook-form'; +import { useEffect, useState, useCallback } from 'react'; +import { Restaurant } from '../../interfaces/Orders'; + +const useFoodSelection = (menuChoice?: Restaurant) => { + const { handleSubmit, register, errors, getValues } = useForm(); + const [multipleSupport, setMultiple] = useState([]); + + const validate = useCallback(() => { + const allValues = getValues(); + let matchedAllowedChoices = false; + menuChoice?.customizations.forEach(item => { + if (item?.allowedChoices) { + const _matchedAllowedChoices = Object.keys(allValues).filter( + key => !!(allValues as any)[key] + ); + matchedAllowedChoices = + _matchedAllowedChoices.length === item.allowedChoices; + } + }); + return matchedAllowedChoices; + }, [menuChoice?.customizations]); + + useEffect(() => { + const newMultiple: boolean[] = []; + menuChoice?.customizations.forEach(item => { + newMultiple.push(!!item?.allowedChoices); + }); + setMultiple(newMultiple); + }, [menuChoice?.customizations]); + + return { + handleSubmit, + register, + multipleSupport, + errors, + validate, + getValues + }; +}; + +export default useFoodSelection; diff --git a/commons/hooks/useMyOrder.tsx b/commons/hooks/useMyOrder.tsx new file mode 100644 index 0000000..3233a8e --- /dev/null +++ b/commons/hooks/useMyOrder.tsx @@ -0,0 +1,33 @@ +import { useCallback } from 'react'; +import { + FetchResult, + isFetchingCompleted, + isFetchingFailed +} from '../../interfaces/Commons'; +import { MyOrder } from '../../interfaces/Orders'; +import { FirebaseModule, useFirestoreSnapshot } from '../firebase'; + +const useMyOrder = (): FetchResult => { + const getDocument = useCallback( + (firebase: FirebaseModule) => + firebase + .getEnvDoc() + .collection('foodChoices') + .doc(firebase.auth().currentUser!.uid), + [] + ); + const snapshotFetchResult = useFirestoreSnapshot(getDocument); + if (isFetchingFailed(snapshotFetchResult)) { + return { status: 'error', error: snapshotFetchResult.error }; + } + if (!isFetchingCompleted(snapshotFetchResult)) { + return { status: 'loading' }; + } + const snapshot = snapshotFetchResult.data; + if (!snapshot.exists) { + return { status: 'completed', data: null }; + } + return { status: 'completed', data: snapshot.data() as any }; +}; + +export default useMyOrder; diff --git a/commons/hooks/useOrders.ts b/commons/hooks/useOrders.ts new file mode 100644 index 0000000..862cd00 --- /dev/null +++ b/commons/hooks/useOrders.ts @@ -0,0 +1,37 @@ +import { useCallback } from 'react'; +import { RestaurantGroup } from '../../interfaces/Orders'; +import { + FetchResult, + isFetchingFailed, + isFetchingCompleted +} from '../../interfaces/Commons'; +import { FirebaseModule, useFirestoreSnapshot } from '../firebase'; + +type FoodConfiguration = { + menu: { + groups: RestaurantGroup[]; + }; + orderingPeriodEndTime: number; +}; + +const useOrders = (): FetchResult => { + const getDocument = useCallback( + (firebase: FirebaseModule) => + firebase + .getEnvDoc() + .collection('configuration') + .doc('food'), + [] + ); + const snapshotFetchResult = useFirestoreSnapshot(getDocument); + if (isFetchingFailed(snapshotFetchResult)) { + return { status: 'error', error: snapshotFetchResult.error }; + } + if (!isFetchingCompleted(snapshotFetchResult)) { + return { status: 'loading' }; + } + const snapshot = snapshotFetchResult.data; + return { status: 'completed', data: snapshot.data() as any }; +}; + +export default useOrders; diff --git a/commons/hooks/useRouteData.ts b/commons/hooks/useRouteData.ts new file mode 100644 index 0000000..a46341d --- /dev/null +++ b/commons/hooks/useRouteData.ts @@ -0,0 +1,25 @@ +import { useRouter } from 'next/router'; +import { useMemo } from 'react'; +import allRoutesData from '../../utils/data.route.json'; +import { RouteData } from '../../interfaces/Commons'; + +const defaultRouteData = { + hasNavbar: true, + title: 'JavaScript Bangkok 1.0.0' +}; + +const useRouteData = (): RouteData => { + const router = useRouter(); + + const routeData = useMemo((): RouteData => { + const path = router.pathname; + if (path in allRoutesData) { + return (allRoutesData as any)[path]; + } + return defaultRouteData; + }, [router.pathname]); + + return routeData; +}; + +export default useRouteData; diff --git a/commons/stores/authModalStores.ts b/commons/stores/authModalStores.ts new file mode 100644 index 0000000..fe48761 --- /dev/null +++ b/commons/stores/authModalStores.ts @@ -0,0 +1,63 @@ +import { ModalType } from '../../interfaces/Commons'; + +const createModalStore = (timeout: number, defaultOpen = true) => ({ + isModalOpen: defaultOpen, + hiddenClassTimer: null as NodeJS.Timeout | null, + currentModal: ModalType.normal as ModalType, + + /** + * Number of components currently requesting for the modal to be open. + */ + referenceCount: 0, + + setModalType(modalType: ModalType) { + this.currentModal = modalType; + }, + + /** + * Requests for a modal to be open. + * This handles the race condition where multiple components requests the same modal. + * Once the modal is no longer needed, call `release()` to close. + */ + requestModal() { + const shouldOpen = this.referenceCount === 0; + this.referenceCount += 1; + if (shouldOpen) { + this.setModalOpen(true); + } + return { + release: () => { + this.referenceCount -= 1; + const shouldClose = this.referenceCount === 0; + if (shouldClose) { + this.setModalOpen(false); + } + } + }; + }, + setModalOpen(isOpen: boolean) { + if (this.isModalOpen === isOpen && !isOpen) { + return; + } + this.isModalOpen = isOpen; + if (this.hiddenClassTimer) { + clearTimeout(this.hiddenClassTimer); + } + if (!isOpen) { + this.isAnimating = true; + this.hiddenClassTimer = setTimeout(() => { + this.isAnimating = false; + this.isHidden = true; + }, timeout); + } else { + this.isHidden = false; + this.isAnimating = false; + } + }, + isAnimating: false, + isHidden: true +}); + +export type ModalStore = ReturnType; + +export default createModalStore; diff --git a/commons/stores/userStores.tsx b/commons/stores/userStores.tsx new file mode 100644 index 0000000..919de9c --- /dev/null +++ b/commons/stores/userStores.tsx @@ -0,0 +1,12 @@ +import { UserInfo } from '../../interfaces/Users'; + +const createUserStore = () => ({ + userInfo: undefined as UserInfo | undefined, + setUserInfo(userInfo: UserInfo | undefined): void { + this.userInfo = userInfo; + } +}); + +export type UserStore = ReturnType; + +export default createUserStore; diff --git a/components/announcements/Announcements.tsx b/components/announcements/Announcements.tsx new file mode 100644 index 0000000..dbd30d4 --- /dev/null +++ b/components/announcements/Announcements.tsx @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/indent */ +import React, { useState, useEffect, useMemo } from 'react'; +import { useId } from 'react-id-generator'; +import { getFirebase } from '../../commons/firebase'; + +export function useAnnouncement() { + const [announcement, setAnnouncement] = useState< + { text: string } | 'loading' | null + >('loading'); + useEffect(() => { + let cancel: () => void; + const cancelPromise = new Promise(resolve => { + cancel = resolve; + }); + (async () => { + const firebase = await getFirebase(); + const ref = firebase.getEnvRef().child('announcement'); + const onValue = (snapshot: any) => { + setAnnouncement(snapshot.val()); + }; + // eslint-disable-next-line no-console + ref.on('value', onValue, console.error); + cancelPromise.then(() => { + ref.off('value', onValue); + }); + })(); + return () => cancel(); + }, []); + return announcement; +} + +const AnnouncementContent: React.FC<{ text: string; headerId: string }> = ({ + text, + headerId +}) => { + const Announcement = useMemo( + () => ( +

+ ), + [text] + ); + + return ( +

+

+ Announcements +

+ {Announcement} +
+ ); +}; + +export default function Announcements() { + const [headerId] = useId(1, 'Announcement'); + const announcement = useAnnouncement(); + + if (announcement === 'loading') { + return
(Loading announcement)
; + } + if (!announcement) return null; + + return ; +} diff --git a/components/authentication/index.tsx b/components/authentication/index.tsx new file mode 100644 index 0000000..48aaa15 --- /dev/null +++ b/components/authentication/index.tsx @@ -0,0 +1,315 @@ +/* eslint-disable @typescript-eslint/indent */ +import { ReactNode, useContext, useEffect, useMemo, useState } from 'react'; +import rootContext from '../../commons/context.root'; +import { + FirestoreSnapshot, + getEnvName, + getFirebase, + useFirebase, + User +} from '../../commons/firebase'; +import { + CompletedFetchResult, + FetchResult, + isFetching, + isFetchingCompleted +} from '../../interfaces/Commons'; +import { registerTestCommand } from '../../commons/globals'; +import { UserInfo } from '../../interfaces/Users'; +import Loading from '../../commons/components/Loading'; + +export type ProfileData = { + firstname: string; + lastname: string; + email: string; + referenceCode: string; + ticketType: string; +}; + +type AuthenticatedState = { + profile: ProfileData; + uid: string; +}; + +export type AuthenticationState = FetchResult; + +/** + * Returns the current authentication state. + */ +export function useAuthenticationState(): AuthenticationState { + const firebaseFetchResult = useFirebase(); + const [firebaseUser, setFirebaseUser] = useState( + 'loading' + ); + const [profileSnapshot, setProfileSnapshot] = useState< + FirestoreSnapshot | 'loading' + >('loading'); + + useEffect(() => { + if (!isFetchingCompleted(firebaseFetchResult)) { + return () => {}; + } + const firebase = firebaseFetchResult.data; + return firebase.auth().onAuthStateChanged(setFirebaseUser); + }, [firebaseFetchResult]); + + useEffect(() => { + if (!isFetchingCompleted(firebaseFetchResult)) { + return () => {}; + } + const firebase = firebaseFetchResult.data; + if (firebaseUser === 'loading') { + return () => {}; + } + if (!firebaseUser) { + setProfileSnapshot('loading'); + return () => {}; + } + setProfileSnapshot('loading'); + return firebase + .getEnvDoc() + .collection('profiles') + .doc(firebaseUser.uid) + .onSnapshot(setProfileSnapshot); + }, [firebaseFetchResult, firebaseUser]); + + if (firebaseUser === 'loading') { + return { status: 'loading' }; + } + if (!firebaseUser) { + return { status: 'completed', data: null }; + } + if (profileSnapshot === 'loading') { + return { status: 'loading' }; + } + if (!profileSnapshot.exists) { + return { status: 'completed', data: null }; + } + return { + status: 'completed', + data: { + uid: firebaseUser.uid, + profile: profileSnapshot.data() as UserInfo + } + }; +} + +export function isAuthenticated( + state: AuthenticationState +): state is CompletedFetchResult { + return isFetchingCompleted(state) && state.data !== null; +} + +export async function logoutFromFirebase() { + const firebase = await getFirebase(); + await firebase.auth().signOut(); +} + +/** + * Returns an object with methods to authenticate user. + */ +export function useAuthenticationController() { + return useMemo( + () => ({ + async loginAsTestUser(ticketID: string) { + if (getEnvName() === 'production') { + throw new Error( + 'Eventpop authentication is not implemented yet. To log in, please run the app in test mode by appending ?env=test to URL' + ); + } + const firebase = await getFirebase(); + const getTestTokenFromApp = firebase + .functions('asia-northeast1') + .httpsCallable('getTestTokenFromApp'); + const token = await getTestTokenFromApp({ uid: ticketID }); + await firebase.auth().signInWithCustomToken(token.data.token); + }, + async loginWithEventpop() { + const firebase = await getFirebase(); + const url = `https://www.eventpop.me/oauth/authorize?${[ + 'client_id=ba3bd8b639664043a8f1c3c6bef737620a84841d7e5a38aa84fdbf872920ab71', + 'redirect_uri=https://javascriptbangkok.com/1.0.0/eventpop_oauth_callback.html', + 'response_type=code' + ].join('&')}`; + const features = + 'width=720,height=480,location=1,resizable=1,statusbar=1,toolbar=0'; + const popup = window.open(url, '_blank', features); + if (!popup) { + throw new Error('Cannot open pop-up! Please check your ad-blocker.'); + } + const code = await new Promise((resolve, reject) => { + const listener = (e: MessageEvent) => { + if ( + e.origin === 'https://javascriptbangkok.com' && + typeof e.data === 'string' && + e.data.startsWith('?') + ) { + const { source } = e; + const _code = e.data.match(/code=([^&]+)/)?.[1]; + resolve(_code); + (source as any)?.postMessage( + 'close', + 'https://javascriptbangkok.com' + ); + window.removeEventListener('message', listener); + } + }; + window.addEventListener('message', listener); + const interval = setInterval(() => { + if (popup.closed) { + clearInterval(interval); + reject(new Error('Pop-up is closed')); + } + }, 100); + }); + const signInWithEventpop = firebase + .functions('asia-northeast1') + .httpsCallable('signInWithEventpop'); + const signInResponse = await signInWithEventpop({ + env: getEnvName(), + code + }); + const { result } = signInResponse.data; + if (result.length === 0) { + throw new Error('You do not have any registered ticket.'); + } + const selectedTicket = (() => { + if (result.length === 1) { + return result[0]; + } + const message = `You have multiple tickets. Please enter the number of the ticket you want to sign in with:\n\n${result + .map((row: any, index: number) => { + return `${index + 1}. ${row.profile.firstname} ${ + row.profile.lastname + } [${row.profile.referenceCode}]`; + }) + .join('\n')}`; + for (;;) { + // eslint-disable-next-line no-alert + const answer = +(prompt(message) as any); + if (answer && result[answer - 1]) { + return result[answer - 1]; + } + } + })(); + await firebase + .auth() + .signInWithCustomToken(selectedTicket.firebaseToken); + }, + async loginWithEventpopInfo(referenceCode: string, phoneNumber: string) { + const firebase = await getFirebase(); + const signInWithEventpopInfo = firebase + .functions('asia-northeast1') + .httpsCallable('signInWithEventpopInfo'); + const signInResponse = await signInWithEventpopInfo({ + env: getEnvName(), + referenceCode, + phoneNumber + }); + const { result } = signInResponse.data; + if (result.length === 0) { + throw new Error( + 'We did not find a valid ticket from your information provided.' + ); + } + const selectedTicket = (() => { + if (result.length === 1) { + return result[0]; + } + const message = `You have multiple tickets. Please enter the number of the ticket you want to sign in with:\n\n${result + .map((row: any, index: number) => { + return `${index + 1}. ${row.profile.firstname} ${ + row.profile.lastname + } [${row.profile.referenceCode}]`; + }) + .join('\n')}`; + for (;;) { + // eslint-disable-next-line no-alert + const answer = +(prompt(message) as any); + if (answer && result[answer - 1]) { + return result[answer - 1]; + } + } + })(); + await firebase + .auth() + .signInWithCustomToken(selectedTicket.firebaseToken); + }, + async logout() { + await logoutFromFirebase(); + } + }), + [] + ); +} + +function useIsClientSide() { + const [flag, setFlag] = useState(false); + useEffect(() => { + setFlag(true); + }, []); + return flag; +} + +/** + * This component renders the children only if user is authenticated. + * Otherwise, it requests the auth modal to be displayed. + */ +export function RequiresAuthentication(props: { + children: ReactNode; + fallback?: ReactNode; + checking?: ReactNode; +}) { + const { + children, + checking = , + fallback = null + } = props; + const authState = useAuthenticationState(); + const mustDisplayModal = + !isFetching(authState) && !isAuthenticated(authState); + const { authModalStore } = useContext(rootContext); + + useEffect(() => { + if (!mustDisplayModal) { + return () => {}; + } + const modal = authModalStore.requestModal(); + return () => { + modal.release(); + }; + }, [mustDisplayModal]); + + if (isFetching(authState)) { + return <>{checking}; + } + if (!isAuthenticated(authState)) { + return <>{fallback}; + } + return <>{children}; +} + +function DefaultAuthenticationChecking() { + const isClientSide = useIsClientSide(); + return ( + + ); +} + +export function withRequiredAuthentication( + BaseComponent: React.ComponentType +): React.ComponentType { + return function Wrapped(props: T) { + return ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + ); + }; +} + +registerTestCommand('logoutFromFirebase', logoutFromFirebase); diff --git a/components/conference/Staff.tsx b/components/conference/Staff.tsx new file mode 100644 index 0000000..7b7172b --- /dev/null +++ b/components/conference/Staff.tsx @@ -0,0 +1,86 @@ +import sanitizeHTML from 'sanitize-html'; +import { useMemo } from 'react'; +import { Schedule } from '../../interfaces/Schedule'; +import { ModalStore } from '../../commons/stores/authModalStores'; +import Modal from '../../commons/components/Modal'; + +interface Props { + schedule?: Schedule; + modalStore: ModalStore; +} + +const Staff: React.FC = ({ schedule, modalStore }) => { + const description = useMemo(() => { + return ( + schedule?.description && ( +

+ ) + ); + }, [schedule?.description]); + + const about = useMemo(() => { + return ( + schedule?.about && ( +

+ ) + ); + }, [schedule?.description]); + + return ( + + {!modalStore.isHidden && ( + <> + Italian Trulli +

+

+ {schedule?.speaker} +

+

+ {schedule?.position} +

+

{schedule?.title}

+
+ {description} +

+ About The Speaker +

+ {about} + {schedule?.url && schedule.email && ( + <> +

+ Contact +

+ {schedule.url && ( +

+ Website: {schedule.url} +

+ )} + {schedule.email &&

Email: {schedule.email}

} + + )} + + )} + + ); +}; + +export default Staff; diff --git a/components/conference/index.tsx b/components/conference/index.tsx new file mode 100644 index 0000000..8b917b2 --- /dev/null +++ b/components/conference/index.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Schedule } from '../../interfaces/Schedule'; + +interface PropTypes { + schedules: Schedule[]; + openSchedule: (shedule: Schedule) => void; +} + +const ScheduleBox: React.FC = observer( + ({ schedules, openSchedule }) => { + return ( +
+ {schedules?.map((e: Schedule) => { + const disabled = !e.description; + return ( +
openSchedule(e), + onClick: () => openSchedule(e), + tabIndex: 0 + })} + key={e.key} + className={`bg-white font-bold mx-4 ${ + e.happening ? 'border-2 border-yellow-dark' : '' + } mt-4 p-4 rounded-lg + ${e.happened && !e.happening ? 'opacity-50' : 'opacity-100'} ${ + disabled ? '' : 'cursor-pointer' + }`} + > +
+ {e.hours}:{e.minutes} +
+
{e.title}
+ {e.speaker && ( +
+ By {e.speaker} +
+ )} +
+ ); + })} +
+ ); + } +); +export default ScheduleBox; diff --git a/components/nav.tsx b/components/nav.tsx deleted file mode 100644 index dd4ac2a..0000000 --- a/components/nav.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; -import Link from 'next/link'; - -interface Links { - href: string; - label: string; - key?: string; -} - -const links: Links[] = [ - { href: 'https://zeit.co/now', label: 'ZEIT' }, - { href: 'https://github.com/zeit/next.js', label: 'GitHub' } -].map(link => { - const a = { ...link }; - (a as Links).key = `nav-link-${link.href}-${link.label}`; - return link; -}); - -const Nav: React.FC = () => ( - -); - -export default Nav; diff --git a/components/networking/BadgeList.tsx b/components/networking/BadgeList.tsx new file mode 100644 index 0000000..6ecb1af --- /dev/null +++ b/components/networking/BadgeList.tsx @@ -0,0 +1,64 @@ +import React from 'react'; + +interface Props { + id: number | undefined; + className?: string; +} + +const BadgeList: React.FC = ({ id, className }) => { + switch (id) { + case 1: + return ( + javascript + ); + case 2: + return ( + angular + ); + case 3: + return ( + react + ); + case 4: + return ( + nodejs + ); + case 5: + return ( + firebase + ); + case 6: + return ( + vue + ); + case 7: + return ( + typescript + ); + default: { + return null; + } + } +}; + +export default BadgeList; diff --git a/components/networking/FriendList.tsx b/components/networking/FriendList.tsx new file mode 100644 index 0000000..08a4724 --- /dev/null +++ b/components/networking/FriendList.tsx @@ -0,0 +1,40 @@ +/* eslint-disable @typescript-eslint/indent */ +import React from 'react'; +import { Network } from '../../interfaces/Users'; +import BadgeList from './BadgeList'; + +interface Props { + openModal: (profile: Network) => void; + networks: Network[] | any; +} + +const FriendList: React.FC = ({ networks, openModal }) => { + return networks + ? networks.map((network: Network, index: number) => { + return ( +
openModal(network)} + onKeyPress={() => openModal(network)} + role='button' + key={network.uid} + className='px-3 cursor-pointer' + tabIndex={0} + > +
+
+ +

{network.name}

+
+ arrow +
+
+ ); + }) + : null; +}; + +export default FriendList; diff --git a/components/networking/ProfileModal.tsx b/components/networking/ProfileModal.tsx new file mode 100644 index 0000000..5c387a3 --- /dev/null +++ b/components/networking/ProfileModal.tsx @@ -0,0 +1,36 @@ +import { useState, useEffect } from 'react'; +import { ModalStore } from '../../commons/stores/authModalStores'; +import Modal from '../../commons/components/Modal'; +import { Network } from '../../interfaces/Users'; +import BadgeList from './BadgeList'; +import { getNetworkingProfile } from '../../commons/hooks/networkingHooks'; + +interface Props { + profile?: Network; + modalStore: ModalStore; +} + +const ProfileModal: React.FC = ({ profile, modalStore }) => { + const [_profile, setProfile] = useState(); + + useEffect(() => { + getNetworkingProfile(`${profile?.uid}`).then(setProfile); + }, [profile?.uid]); + return ( + + {!modalStore.isHidden && ( + <> + +
{profile?.name}
+
{_profile?.data?.bio}
+ + )} +
+ ); +}; + +export default ProfileModal; diff --git a/components/networking/timeout.tsx b/components/networking/timeout.tsx new file mode 100644 index 0000000..c08e48a --- /dev/null +++ b/components/networking/timeout.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Card from '../../commons/components/Card'; + +const TimeOut: React.FC = () => { + return ( + +
+
+ Timeout!!! +
+
+
Try again...
+
JavaScript Bangkok 2.0.0
+
+
+
+ ); +}; + +export default TimeOut; diff --git a/components/networking/winner.tsx b/components/networking/winner.tsx new file mode 100644 index 0000000..20de9af --- /dev/null +++ b/components/networking/winner.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import Card from '../../commons/components/Card'; + +const Winner: React.FC = () => { + return ( + +
+ Congratulations!
You are the winner. +
+
+ Please claim your reward
+ at the information desk. +
+
+ ); +}; + +export default Winner; diff --git a/components/order-food/CountDown.tsx b/components/order-food/CountDown.tsx new file mode 100644 index 0000000..153fdae --- /dev/null +++ b/components/order-food/CountDown.tsx @@ -0,0 +1,49 @@ +import React, { useState, useEffect, useMemo } from 'react'; + +interface Props { + due: number; +} + +function padZero(n: string, width: number) { + return n.length >= width ? n : new Array(width - n.length + 1).join('0') + n; +} + +export function useCurrentTime() { + const [time, setTime] = useState(null); + + useEffect(() => { + setTime(Date.now()); + const interval = setInterval(() => { + setTime(Date.now()); + }, 1000); + return () => clearInterval(interval); + }, []); + + return time; +} + +export const formatTimeLeft = (time: number | null, due: number) => { + if (time === null) return '…'; + const difference = Math.floor((due - time) / 1000); + if (difference < 0) return '00:00:00'; + const seconds = difference % 60; + const minutes = Math.floor(difference / 60) % 60; + const hours = Math.floor(difference / 3600) % 24; + const days = Math.floor(difference / 86400); + const parts = [ + ...(days > 0 ? [days] : []), + padZero(`${hours}`, 2), + padZero(`${minutes}`, 2), + padZero(`${seconds}`, 2) + ]; + return parts.join(':'); +}; + +const Countdown: React.FC = ({ due }) => { + const time = useCurrentTime(); + const timeLeft = useMemo(() => formatTimeLeft(time, due), [time, due]); + + return <>{timeLeft}; +}; + +export default Countdown; diff --git a/components/order-food/FloorMap.css b/components/order-food/FloorMap.css new file mode 100644 index 0000000..2c9230a --- /dev/null +++ b/components/order-food/FloorMap.css @@ -0,0 +1,49 @@ +.FloorMap svg, +.FloorMap img { + max-width: 100%; + height: auto; +} + +.FloorMap img { + display: block; +} + +[data-highlight='IamThaiPasta'] path#IamThaiPasta, +[data-highlight='Doodee'] path#Doodee, +[data-highlight='NoodlesAndMore'] path#NoodlesAndMore, +[data-highlight='HuaSengHong'] path#HuaSengHong, +[data-highlight='MarugameSeimen'] path#MarugameSeimen, +[data-highlight='Karayama'] path#Karayama, +[data-highlight='SushiDen'] path#SushiDen, +[data-highlight='HongKongNoodle'] path#HongKongNoodle, +[data-highlight='Sutadonya'] path#Sutadonya, +[data-highlight='Kin'] path#Kin { + animation: 2s FloorMapHighlight linear infinite; +} + +.FloorMap:not([data-highlight='IamThaiPasta']) #v_IamThaiPasta, +.FloorMap:not([data-highlight='Doodee']) #v_Doodee, +.FloorMap:not([data-highlight='NoodlesAndMore']) #v_NoodlesAndMore, +.FloorMap:not([data-highlight='HuaSengHong']) #v_HuaSengHong, +.FloorMap:not([data-highlight='MarugameSeimen']) #v_MarugameSeimen, +.FloorMap:not([data-highlight='Karayama']) #v_Karayama, +.FloorMap:not([data-highlight='SushiDen']) #v_SushiDen, +.FloorMap:not([data-highlight='HongKongNoodle']) #v_HongKongNoodle, +.FloorMap:not([data-highlight='Sutadonya']) #v_Sutadonya, +.FloorMap:not([data-highlight='Kin']) #v_Kin { + opacity: 0; +} + +@keyframes FloorMapHighlight { + from { + fill: #e7b109; + animation-timing-function: ease-in-out; + } + 50% { + fill: #ce716f; + animation-timing-function: ease-in-out; + } + to { + fill: #e7b109; + } +} diff --git a/components/order-food/FloorMap.tsx b/components/order-food/FloorMap.tsx new file mode 100644 index 0000000..c0cea09 --- /dev/null +++ b/components/order-food/FloorMap.tsx @@ -0,0 +1,575 @@ +import './FloorMap.css'; + +const availableImages = [ + 'IamThaiPasta', + 'Doodee', + 'NoodlesAndMore', + 'HuaSengHong', + 'MarugameSeimen', + 'Karayama', + 'SushiDen', + 'HongKongNoodle', + 'Sutadonya', + 'Kin' +]; + +const FloorMap: React.FC<{ map: React.ReactNode; highlight: string }> = ({ + map, + highlight +}) => { + return ( +
+ {map} + {availableImages.includes(highlight) ? ( + Restaurant exterior + ) : null} +
+ ); +}; + +export default FloorMap; + +export const floor4 = ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sutadonya + + + + + Hong Kong + + + Noodle + + + + + Sushi + + + Den + + + + + Hua Seng + + + Hong + + + + + Karayama + + + + + Marugame + + + Seimen + + + + + Noodles + + + & More + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const floor5 = ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Kuek + + + Kak by + + + Doodee + + + + + Kin + + + + + I Am + + + Thai Pasta + + + + + + + + + + + + + + + + +); diff --git a/components/order-food/FoodAvailabilityHooks.tsx b/components/order-food/FoodAvailabilityHooks.tsx new file mode 100644 index 0000000..86431c2 --- /dev/null +++ b/components/order-food/FoodAvailabilityHooks.tsx @@ -0,0 +1,63 @@ +import { useMemo } from 'react'; +import { Restaurant, Menu, Food } from '../../interfaces/Orders'; +import { + FetchResult, + isFetchingFailed, + isFetchingCompleted +} from '../../interfaces/Commons'; +import { useRealtimeDatabaseSnapshot } from '../../commons/firebase'; + +export const useRestaurantAvailability = ( + restaurant: Restaurant +): FetchResult => { + const dataState = useRealtimeDatabaseSnapshot(firebase => + firebase + .getEnvRef() + .child('selectionStats') + .child(restaurant.id) + ); + return useMemo(() => { + if (isFetchingFailed(dataState)) { + return { status: 'error', error: dataState.error }; + } + if (!isFetchingCompleted(dataState)) { + return { status: 'loading' }; + } + const result = dataState.data.val() || 0; + return { + status: 'completed', + data: Math.max(0, restaurant.availability - result) + }; + }, [dataState]); +}; + +export const useCustomizationChoiceAvailability = ( + restaurant: Restaurant, + customization: Menu, + customizationChoice: Food +): FetchResult => { + const dataState = useRealtimeDatabaseSnapshot(firebase => + firebase + .getEnvRef() + .child('selectionStats') + .child( + [restaurant.id, customization.id, customizationChoice.id].join('-') + ) + ); + return useMemo(() => { + if (customizationChoice.availability == null) { + return { status: 'completed', data: null }; + } + if (isFetchingFailed(dataState)) { + return { status: 'error', error: dataState.error }; + } + if (!isFetchingCompleted(dataState)) { + return { status: 'loading' }; + } + const result = dataState.data.val() || 0; + return { + status: 'completed', + data: Math.max(0, customizationChoice.availability - result) + }; + }, [dataState]); +}; diff --git a/components/order-food/OrderFood.tsx b/components/order-food/OrderFood.tsx new file mode 100644 index 0000000..a7398ec --- /dev/null +++ b/components/order-food/OrderFood.tsx @@ -0,0 +1,114 @@ +import { useMemo } from 'react'; +import { useId } from 'react-id-generator'; +import Card from '../../commons/components/Card'; +import Button from '../../commons/components/Button'; +import { RestaurantGroup, MyOrder } from '../../interfaces/Orders'; +import ErrorMessage from '../../commons/components/ErrorMessage'; +import FloorMap, { floor4, floor5 } from './FloorMap'; + +interface Props { + className?: string; + menu: RestaurantGroup[]; + myOrder: MyOrder; + onChangeSelection: () => void; +} + +const groupToFloorMap: { + [title: string]: { title: string; mapData: React.ReactNode }; +} = { + 'Floor 4: Restaurants': { title: 'Floor 4', mapData: floor4 }, + 'Floor 5: Restaurants': { title: 'Floor 5', mapData: floor5 } +}; + +const OrderFood: React.FC = ({ + className, + menu, + myOrder, + onChangeSelection +}) => { + const restaurants = useMemo(() => menu.flatMap(m => m.choices), [menu]); + const [headingId] = useId(1, 'OrderFood'); + + const restaurant = restaurants.find(r => r.id === myOrder.restaurantId); + if (!restaurant) { + return ( + + ); + } + + const groupTitle = menu.find(r => r.choices.includes(restaurant))?.title; + + const renderTitle = (text: string) => { + const m = String(text).match(/^([^]+)\s*?\(([^]+?)\)$/); + if (m) { + return ( + <> + {m[2]} + {m[1]} + + ); + } + return {text}; + }; + const map = groupToFloorMap[groupTitle || '']; + + return ( +
+

+ Your Food Selection +

+ +

+ {restaurant.title} +

+ {restaurant.customizations.map(customization => { + return ( +
+ {customization.title}: + {customization.choices + .filter(c => + (myOrder.customizations[customization.id] || []).includes( + c.id + ) + ) + .map(choice => { + return ( +

+ {renderTitle(choice.title)} +

+ ); + })} +
+ ); + })} + +
+ {!!map && ( + <> +

+ {map.title} Map +

+ + + + + )} +
+ ); +}; + +export default OrderFood; diff --git a/components/order-food/RestaurantList.tsx b/components/order-food/RestaurantList.tsx new file mode 100644 index 0000000..3fe02dc --- /dev/null +++ b/components/order-food/RestaurantList.tsx @@ -0,0 +1,77 @@ +import { Button } from 'reakit/Button'; +import { useContext, useMemo } from 'react'; +import { useId } from 'react-id-generator'; +import Card from '../../commons/components/Card'; +import { Restaurant } from '../../interfaces/Orders'; +import { currentMenuContext } from '../../pages/user/order'; +import { useRestaurantAvailability } from './FoodAvailabilityHooks'; +import { isFetchingCompleted } from '../../interfaces/Commons'; + +interface ListItemProps { + lastItem: boolean; + restaurant: Restaurant; +} + +const ListItem: React.FC = ({ lastItem, restaurant }) => { + const { title, info } = restaurant; + const { orderFood } = useContext(currentMenuContext); + const [titleId] = useId(1, 'RestaurantListItem'); + const availabilityState = useRestaurantAvailability(restaurant); + const isDisabled = !(availabilityState.data && availabilityState.data > 0); + const availabilityText = useMemo(() => { + if (!isFetchingCompleted(availabilityState)) { + return <>…; + } + const availability = availabilityState.data; + return ( + <> + {availability} left + + ); + }, [availabilityState]); + return ( + + ); +}; + +const RestaurantList: React.FC<{ restaurants: Restaurant[] }> = ({ + restaurants +}) => { + return ( + + {restaurants.map((restaurant, index) => ( + + ))} + + ); +}; + +export default RestaurantList; diff --git a/components/order-food/SelectFoodContent.tsx b/components/order-food/SelectFoodContent.tsx new file mode 100644 index 0000000..902b4b2 --- /dev/null +++ b/components/order-food/SelectFoodContent.tsx @@ -0,0 +1,221 @@ +import React, { useMemo, useState } from 'react'; +import { Restaurant, Menu, Food } from '../../interfaces/Orders'; +import { ModalStore } from '../../commons/stores/authModalStores'; +import useFoodSelection from '../../commons/hooks/useFoodSelection'; +import Button from '../../commons/components/Button'; +import submitFoodOrder from '../../commons/hooks/submitFoodOrder'; +import { + ModalType, + FetchResult, + isFetchingCompleted +} from '../../interfaces/Commons'; +import { useCustomizationChoiceAvailability } from './FoodAvailabilityHooks'; +import TextSpinner from '../../commons/components/TextSpinner'; + +interface PropTypes { + menuChoice?: Restaurant; + modalStore: ModalStore; + onFinish: () => void; +} + +function getCustomizations( + menuChoice: Restaurant, + multipleSupport: boolean[], + values: any +): { [key: string]: string[] } { + const customizations: { [key: string]: string[] } = {}; + // eslint-disable-next-line no-restricted-syntax + for (const [index, item] of Array.from(menuChoice.customizations.entries())) { + const isMultipleSupport = multipleSupport[index]; + if (isMultipleSupport) { + // eslint-disable-next-line no-restricted-syntax + for (const choice of item.choices) { + if (values[choice.id]) { + if (!customizations[item.id]) customizations[item.id] = []; + customizations[item.id].push(choice.id); + } + } + } else { + customizations[item.id] = [values[item.id]]; + } + } + return customizations; +} + +const SelectFoodContent: React.FC = ({ + menuChoice, + modalStore, + onFinish +}) => { + const { + handleSubmit, + register, + multipleSupport, + errors, + validate, + getValues + } = useFoodSelection(menuChoice); + + const [isSubmitting, setIsSubmitting] = useState(false); + + const onSubmit = async (values: any) => { + setIsSubmitting(true); + try { + if (!menuChoice) { + throw new Error('No restaurant selected.'); + } + const customizations = getCustomizations( + menuChoice, + multipleSupport, + values + ); + await submitFoodOrder(menuChoice.id, customizations); + modalStore.setModalOpen(false); + onFinish(); + } catch (e) { + // @TODO check if error is about stock lasts + modalStore.setModalType(ModalType.error); + } finally { + setIsSubmitting(false); + } + }; + + const FoodMenu = useMemo( + () => + menuChoice?.customizations.map((item, index) => { + const customizationChoices = item.choices.map((food, j) => { + const isMultipleSupport = multipleSupport[index]; + return ( + + {availabilityResult => { + const availability = isFetchingCompleted(availabilityResult) + ? availabilityResult.data + : '…'; + const isDisabled = + !isFetchingCompleted(availabilityResult) || + availabilityResult.data === 0; + return ( + isMultipleSupport !== undefined && ( +
+ +
+ ) + ); + }} +
+ ); + }); + + return ( +
+

+ {item.title} +

+ {customizationChoices} +
+ ); + }), + [menuChoice?.customizations, multipleSupport] + ); + + return ( + <> +

+ {menuChoice?.title} +

+
+
{FoodMenu}
+
+ + {Object.entries(errors).length !== 0 && + 'Please select your preferred meal'} + + {isSubmitting ? ( + + ) : ( + + )} +
+
+ + ); +}; + +const CustomizationChoiceAvailabilityConnector: React.FC<{ + restaurant: Restaurant; + customization: Menu; + customizationChoice: Food; + children: (result: FetchResult) => React.ReactNode; +}> = ({ restaurant, customization, customizationChoice, children }) => { + const result = useCustomizationChoiceAvailability( + restaurant, + customization, + customizationChoice + ); + return <>{children(result)}; +}; + +export default SelectFoodContent; diff --git a/components/order-food/SelectFoodModal.tsx b/components/order-food/SelectFoodModal.tsx new file mode 100644 index 0000000..15cfd24 --- /dev/null +++ b/components/order-food/SelectFoodModal.tsx @@ -0,0 +1,48 @@ +/* eslint-disable @typescript-eslint/indent */ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import Modal from '../../commons/components/Modal'; +import { Restaurant } from '../../interfaces/Orders'; +import { ModalStore } from '../../commons/stores/authModalStores'; +import SelectFoodContent from './SelectFoodContent'; +import { ModalType } from '../../interfaces/Commons'; + +interface PropTypes { + menuChoice?: Restaurant; + modalStore: ModalStore; + onFinish: () => void; +} + +const SelectFoodModal: React.FC = observer( + ({ menuChoice, modalStore, onFinish }) => { + return ( + + {!modalStore.isHidden && + modalStore.currentModal === ModalType.normal ? ( + + ) : ( + <> +

{menuChoice?.title}

+
+ + All Sold out here :(
+
+ sorry choose another one +
+ + )} +
+ ); + } +); + +export default SelectFoodModal; diff --git a/contexts/context.user.tsx b/contexts/context.user.tsx deleted file mode 100644 index 2d14474..0000000 --- a/contexts/context.user.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { createContext, useContext, useEffect } from 'react'; -import { when } from 'mobx'; -import UserStore from '../stores/store.user'; - -const authenticate = (): Promise => { - return new Promise(resolve => { - setTimeout(() => resolve({ name: 'new' }), 1000); - }); -}; - -const userContext = createContext(new UserStore()); - -export default userContext; - -export const useUserStore = () => { - const user = useContext(userContext); - useEffect(() => { - when( - () => { - return !!user.token && !user.userInfo; - }, - () => { - authenticate().then(userInfo => { - user.setUserInfo(userInfo); - }); - } - ); - }, []); - return user; -}; diff --git a/interfaces/Badge.ts b/interfaces/Badge.ts new file mode 100644 index 0000000..f827475 --- /dev/null +++ b/interfaces/Badge.ts @@ -0,0 +1,14 @@ +export enum BadgeType { + B1 = 'B1', + B2 = 'B2', + B3 = 'B3', + B4 = 'B4', + B5 = 'B5', + B6 = 'B6', + B7 = 'B7' +} + +export interface Badge { + type: BadgeType; + owner: string; +} diff --git a/interfaces/Commons.ts b/interfaces/Commons.ts new file mode 100644 index 0000000..355ce7f --- /dev/null +++ b/interfaces/Commons.ts @@ -0,0 +1,81 @@ +import { ModalStore } from '../commons/stores/authModalStores'; +import { UserStore } from '../commons/stores/userStores'; +import { NetworkingProfile } from './Users'; + +export type Onclick = + | ((event: React.MouseEvent) => void) + | undefined; + +export interface RouteData { + hasNavbar: boolean; + title: string; +} + +export interface RootStore { + userStore: UserStore; + authModalStore: ModalStore; +} + +export interface FetchResult { + data?: T; + error?: any; + status: 'loading' | 'completed' | 'error'; +} + +export interface CompletedFetchResult extends FetchResult { + data: T; + error?: any; + status: 'completed'; +} + +export interface FailedFetchResult extends FetchResult { + data?: T; + error: any; + status: 'error'; +} + +export interface OngoingFetchResult extends FetchResult { + data?: T; + error?: any; + status: 'loading'; +} + +export interface Networking { + status: string; + data?: NetworkingProfile; + hasAllWinner?: boolean; + isWinner?: boolean; + error?: any; + uuid?: string; +} + +export enum ModalType { + normal, + error +} + +/** + * Checks if fetching is completed. + * If yes, then fetchResult.data will not be undefined. + */ +export function isFetchingCompleted( + f: FetchResult +): f is CompletedFetchResult { + return f.status === 'completed'; +} + +/** + * Checks if there is some fetching ongoing. + */ +export function isFetching(f: FetchResult): f is OngoingFetchResult { + return f.status === 'loading'; +} + +/** + * Checks if fetching result is failed. + */ +export function isFetchingFailed( + f: FetchResult +): f is FailedFetchResult { + return f.status === 'error'; +} diff --git a/interfaces/NetworkingProfile.ts b/interfaces/NetworkingProfile.ts new file mode 100644 index 0000000..c7eb2a5 --- /dev/null +++ b/interfaces/NetworkingProfile.ts @@ -0,0 +1,6 @@ +export interface NetworkingProfile{ + firstname : string, + lastname : string, + badge : number, + bio : string, +} \ No newline at end of file diff --git a/interfaces/Orders.ts b/interfaces/Orders.ts new file mode 100644 index 0000000..1560213 --- /dev/null +++ b/interfaces/Orders.ts @@ -0,0 +1,37 @@ +export interface RestaurantGroup { + title: string; + choices: Restaurant[]; + allowedChoices?: number; +} + +export interface Restaurant { + id: string; + title: string; + availability: number; + info: string; + customizations: Menu[]; +} + +export interface Menu { + id: string; + title: string; + textLength?: string; + allowedChoices?: number; + choices: Food[]; +} + +export interface MyOrder { + restaurantId: string; + customizations: { [key: string]: string[] }; +} + +export interface Food { + id: string; + title: string; + availability?: number; + info?: string; +} + +export interface CurrentMenuContext { + orderFood: (orderData: Restaurant) => void; +} diff --git a/interfaces/Schedule.ts b/interfaces/Schedule.ts new file mode 100644 index 0000000..f50e168 --- /dev/null +++ b/interfaces/Schedule.ts @@ -0,0 +1,15 @@ +export interface Schedule { + key: number; + title: string; + speaker?: string; + hours: string; + minutes: string; + position?: string; + image?: string; + url?: string | null; + email?: string | null; + about?: string | null; + description?: string; + happening: boolean; + happened: boolean; +} diff --git a/interfaces/Users.ts b/interfaces/Users.ts new file mode 100644 index 0000000..fb21481 --- /dev/null +++ b/interfaces/Users.ts @@ -0,0 +1,33 @@ +import { Badge } from './Badge'; + +// export interface UserInfo { +// name: string; +// username: string; +// points: number; +// currentBadge: Badge; +// badges: Badge[]; +// } + +export interface UserInfo { + firstname: string; + lastname: string; + email: string; + referenceCode: string; + ticketType: string; +} + +export interface NetworkingProfile { + key: string; + firstname: string; + lastname: string; + badge: number; + networks: Network[]; + bio: string; +} + +export interface Network { + uid: string; + name: string; + badge: number; + bio: string; +} diff --git a/interfaces/interface.user.ts b/interfaces/interface.user.ts deleted file mode 100644 index 17e12dc..0000000 --- a/interfaces/interface.user.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface UserInfo { - name: string; - username: string; - points: number; -} diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..095f8f1 --- /dev/null +++ b/next.config.js @@ -0,0 +1,3 @@ +const withCSS = require('@zeit/next-css'); + +module.exports = withCSS({}); diff --git a/now.json b/now.json new file mode 100644 index 0000000..8de3d6e --- /dev/null +++ b/now.json @@ -0,0 +1,4 @@ +{ + "name": "jsbangkok-1-companion", + "version": 2 +} diff --git a/package.json b/package.json index 65b2e05..ee4766e 100644 --- a/package.json +++ b/package.json @@ -3,31 +3,52 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", + "dev": "concurrently \"yarn dev:next\" \"yarn dev:style\"", + "dev:next": "next dev", + "dev:style": "postcss ./styles/tailwind.css -o ./styles/index.css -w -m", + "build": "yarn build:style && yarn build:next", + "build:next": "next build", + "build:style": "postcss ./styles/tailwind.css -o ./styles/index.css", "start": "next start", "lint": "eslint . --ext .js,.jsx,.ts,.tsx", + "format": "yarn prettier --write \"./**/*.ts*\"", "export": "next export" }, "dependencies": { - "@emotion/core": "^10.0.22", - "@emotion/styled": "^10.0.23", + "@sentry/browser": "^5.12.1", + "@zeit/next-css": "^1.0.1", + "axios": "^0.19.0", "date-fns": "^2.8.1", + "firebase": "7.7.0", "mobx": "^5.15.1", "mobx-react-lite": "^1.5.2", "next": "9.1.6", + "qrcode.react": "^1.0.0", "react": "16.12.0", - "react-dom": "16.12.0" + "react-dom": "16.12.0", + "react-error-boundary": "^1.2.5", + "react-focus-lock": "^2.2.1", + "react-hook-form": "^4.7.1", + "react-id-generator": "^3.0.0", + "react-qr-reader": "^2.2.1", + "reakit": "^1.0.0-beta.14", + "sanitize-html": "^1.21.1" }, "devDependencies": { "@babel/plugin-proposal-decorators": "^7.7.4", - "@emotion/babel-preset-css-prop": "^10.0.23", + "@fullhuman/postcss-purgecss": "^1.3.0", "@types/node": "^12.12.21", + "@types/qrcode.react": "^1.0.0", "@types/react": "^16.9.17", + "@types/react-dom": "^16.9.5", + "@types/react-qr-reader": "^2.1.2", + "@types/sanitize-html": "^1.20.2", "@types/styled-jsx": "^2.2.8", "@typescript-eslint/eslint-plugin": "^2.12.0", "@typescript-eslint/parser": "^2.12.0", - "babel-plugin-emotion": "^10.0.23", + "autoprefixer": "^9.7.3", + "concurrently": "^5.0.2", + "cssnano": "^4.1.10", "eslint": "^6.8.0", "eslint-config-airbnb-typescript": "^6.3.1", "eslint-config-prettier": "^6.7.0", @@ -38,7 +59,9 @@ "eslint-plugin-prettier": "^3.1.2", "eslint-plugin-react": "^7.17.0", "eslint-plugin-react-hooks": "^2.3.0", + "postcss-cli": "^7.1.0", "prettier": "^1.19.1", + "tailwindcss": "^1.1.4", "typescript": "^3.7.4" } } diff --git a/pages/_app.tsx b/pages/_app.tsx index ded5afe..5ee2378 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,21 +1,73 @@ /* eslint-disable react/jsx-props-no-spreading */ -import { AppProps } from 'next/app'; +import * as Sentry from '@sentry/browser'; +import { observer, useLocalStore } from 'mobx-react-lite'; import { NextPage } from 'next'; -import Nav from '../components/nav'; -import userContext from '../contexts/context.user'; -import UserStore from '../stores/store.user'; +import { AppProps } from 'next/app'; +import Head from 'next/head'; +import { useEffect } from 'react'; +import ErrorBoundary from 'react-error-boundary'; +import AuthModal from '../commons/components/AuthModal'; +import ErrorMessage from '../commons/components/ErrorMessage'; +import Nav from '../commons/components/Nav'; +import PageHeading from '../commons/components/PageHeading'; +import rootContext from '../commons/context.root'; +import useRouteData from '../commons/hooks/useRouteData'; +import createModalStore from '../commons/stores/authModalStores'; +import createUserStore from '../commons/stores/userStores'; +import { + isAuthenticated, + useAuthenticationState +} from '../components/authentication'; +import { RootStore } from '../interfaces/Commons'; +import '../styles/index.css'; + +const App: NextPage = observer(({ Component, pageProps }) => { + useEffect(() => { + Sentry.init({ + dsn: 'https://881d1cb8969940678bdc4bda4394207d@sentry.io/2292751' + }); + }, []); + const routeData = useRouteData(); + const rootStore = useLocalStore( + (): RootStore => ({ + userStore: createUserStore(), + authModalStore: createModalStore(140) + }) + ); + const authenticationState = useAuthenticationState(); + useEffect(() => { + if (isAuthenticated(authenticationState)) { + const authenticatedState = authenticationState.data; + rootStore.userStore.setUserInfo(authenticatedState.profile); + } + }, [authenticationState]); -const App: NextPage = ({ Component, pageProps }) => { return ( <> - -