diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..5a862a6 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,70 @@ +import { createTheme, ThemeProvider as ThemeProviderMui } from '@material-ui/core' +import React, { useEffect } from 'react' +import styled, { ThemeProvider } from 'styled-components' +import { getUser as getUserApi } from './api/lib' +import { useAppDispatch, useAppSelector } from './store/hooks' +import { selectIsOpen, setIsOpen as setIsOpenRedux } from './store/modalSlice' +import { selectMode } from './store/themeSlice' +import { setUser as setUserRedux } from './store/userSlice' +import { GlobalStyle } from './ui/components/GlobalStyles' +import { DarkTheme, LightTheme } from './ui/components/Theme' +import Main from './ui/pages/Main/Main' +import Modal from './ui/surfaces/modal/Modal' + +export default function App() { + const mode = useAppSelector(selectMode) + const modalIsOpen = useAppSelector(selectIsOpen) + const dispatch = useAppDispatch() + + const muiTheme = createTheme({ palette: { type: mode } }) + + useEffect(() => { + async function fetch() { + const user = await getUserApi() + if (user != null) { + dispatch(setUserRedux(user)) + } + } + + fetch() + }, [dispatch]) + + const onClick = (event: React.MouseEvent) => { + event.stopPropagation() + dispatch(setIsOpenRedux(false)) + } + + return ( + + + + + +
+ + + + + + + + ) +} + +const AppContainer = styled.div` + position: relative; +` +const ModalContainer = styled.div` + width: 100vw; + height: 100vh; + display: ${(props) => (props.isOpen ? 'flex' : 'none')}; + flex-direction: row; + justify-content: center; + align-items: center; + background-color: ${({ theme }) => theme.overlay}; + position: absolute; + top: 0; + left: 0; +` + +type ModalContainerProps = { isOpen: boolean } diff --git a/src/api/lib.ts b/src/api/lib.ts new file mode 100644 index 0000000..3c593ca --- /dev/null +++ b/src/api/lib.ts @@ -0,0 +1,53 @@ +import axios from 'axios' +import { user } from '../data/user' +import { Goal, Transaction, User } from './types' + +export const API_ROOT = 'https://fencer-commbank.azurewebsites.net' + +export async function getUser(): Promise { + try { + const response = await axios.get(`${API_ROOT}/api/User/${user.id}`) + return response.data + } catch (error: any) { + return null + } +} + +export async function getTransactions(): Promise { + try { + const response = await axios.get(`${API_ROOT}/api/Transaction/User/${user.id}`) + return response.data + } catch (error: any) { + return null + } +} + +export async function getGoals(): Promise { + try { + const response = await axios.get(`${API_ROOT}/api/Goal/User/${user.id}`) + return response.data + } catch (error: any) { + return null + } +} + +export async function createGoal(): Promise { + try { + const response = await axios.post(`${API_ROOT}/api/Goal`, { + userId: user.id, + targetDate: new Date(), + }) + return response.data + } catch (error: any) { + return null + } +} + +export async function updateGoal(goalId: string, updatedGoal: Goal): Promise { + try { + await axios.put(`${API_ROOT}/api/Goal/${goalId}`, updatedGoal) + return true + } catch (error: any) { + return false + } +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..b668377 --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,69 @@ +export interface Account { + id: string + number: number + name: string + balance: number + accountType: AccountType + applicationId: string + transactionIds: string[] +} + +export interface Application { + id: string + created: Date + modified: Date + accountType: AccountType + applicationStatus: ApplicationStatus + userId: string +} + +export interface Goal { + id: string + name: string + targetAmount: number + balance: number + targetDate: Date + created: Date + accountId: string + transactionIds: string[] + tagIds: string[] + icon: string +} + +export interface Tag { + id: string + name: string +} + +export interface Transaction { + id: string + transactionType: 'Debit' | 'Credit' | 'Transfer' + amount: number + dateTime: Date + goalId?: string + description: string + tagIds: string[] +} + +export interface User { + id: string + name: string + email: string + applicationIds: string[] +} + +export enum AccountType { + GoalSaver, + NetBankSaver, +} + +export enum ApplicationStatus { + Received, + Assigned, + UnderReview, + Approved, + Rejected, +} + +export type ModalContent = Goal +export type ModalType = 'Goal' diff --git a/src/assets/images/commbank.svg b/src/assets/images/commbank.svg new file mode 100644 index 0000000..3eb71a1 --- /dev/null +++ b/src/assets/images/commbank.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/commbank_card.svg b/src/assets/images/commbank_card.svg new file mode 100644 index 0000000..e6e1ede --- /dev/null +++ b/src/assets/images/commbank_card.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/tag.png b/src/assets/images/tag.png new file mode 100644 index 0000000..9b7d0ca Binary files /dev/null and b/src/assets/images/tag.png differ diff --git a/src/data/user.ts b/src/data/user.ts new file mode 100644 index 0000000..85174a9 --- /dev/null +++ b/src/data/user.ts @@ -0,0 +1,8 @@ +/** + * Hardcoding user for MVP + * TODO(Implement auth) + */ + +export const user = { + id: '62a29c15f4605c4c9fa7f306', +} diff --git a/src/index.scss b/src/index.scss new file mode 100644 index 0000000..8267333 --- /dev/null +++ b/src/index.scss @@ -0,0 +1,21 @@ +@import url('https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap'); + +body { + margin: 0; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} + + +* { + font-family: Roboto; +} + +a { + cursor: pointer; +} \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..6b11d2e --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { Provider } from 'react-redux' +import App from './App' +import './index.scss' +import { store } from './store/store' + +ReactDOM.render( + + + + + , + document.getElementById('root'), +) + +export {} diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/store/goalsSlice.ts b/src/store/goalsSlice.ts new file mode 100644 index 0000000..1ed0276 --- /dev/null +++ b/src/store/goalsSlice.ts @@ -0,0 +1,39 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { Goal } from '../api/types' +import { RootState } from './store' + +export interface GoalsState { + map: IdToGoal + list: string[] +} + +export interface IdToGoal { + [id: string]: Goal +} + +const initialState: GoalsState = { + map: {}, + list: [], +} + +export const goalsSlice = createSlice({ + name: 'goal', + initialState, + reducers: { + createGoal: (state, action: PayloadAction) => { + state.map[action.payload.id] = action.payload + state.list.push(action.payload.id) + }, + + updateGoal: (state, action: PayloadAction) => { + state.map[action.payload.id] = action.payload + }, + }, +}) + +export const { createGoal, updateGoal } = goalsSlice.actions + +export const selectGoalsMap = (state: RootState) => state.goals.map +export const selectGoalsList = (state: RootState) => state.goals.list + +export default goalsSlice.reducer diff --git a/src/store/hooks.ts b/src/store/hooks.ts new file mode 100644 index 0000000..597f281 --- /dev/null +++ b/src/store/hooks.ts @@ -0,0 +1,5 @@ +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' +import type { AppDispatch, RootState } from './store' + +export const useAppDispatch = () => useDispatch() +export const useAppSelector: TypedUseSelectorHook = useSelector diff --git a/src/store/modalSlice.ts b/src/store/modalSlice.ts new file mode 100644 index 0000000..c6e2996 --- /dev/null +++ b/src/store/modalSlice.ts @@ -0,0 +1,39 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { ModalContent, ModalType } from '../api/types' +import { RootState } from './store' + +export interface ModalState { + isOpen: boolean + type: ModalType | null + content: ModalContent | null +} + +const initialState: ModalState = { + isOpen: false, + type: null, + content: null, +} + +export const modalSlice = createSlice({ + name: 'modal', + initialState, + reducers: { + setContent: (state, action: PayloadAction) => { + state.content = action.payload + }, + setIsOpen: (state, action: PayloadAction) => { + state.isOpen = action.payload + }, + setType: (state, action: PayloadAction) => { + state.type = action.payload + }, + }, +}) + +export const { setContent, setIsOpen, setType } = modalSlice.actions + +export const selectIsOpen = (state: RootState) => state.modal.isOpen +export const selectContent = (state: RootState) => state.modal.content +export const selectType = (state: RootState) => state.modal.type + +export default modalSlice.reducer diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 0000000..6741bd8 --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,23 @@ +import { Action, configureStore, ThunkAction } from '@reduxjs/toolkit' +import goalsReducer from './goalsSlice' +import modalReducer from './modalSlice' +import themeReducer from './themeSlice' +import userReducer from './userSlice' + +export const store = configureStore({ + reducer: { + goals: goalsReducer, + modal: modalReducer, + theme: themeReducer, + user: userReducer, + }, +}) + +export type AppDispatch = typeof store.dispatch +export type RootState = ReturnType +export type AppThunk = ThunkAction< + ReturnType, + RootState, + unknown, + Action +> diff --git a/src/store/themeSlice.ts b/src/store/themeSlice.ts new file mode 100644 index 0000000..cc4ff15 --- /dev/null +++ b/src/store/themeSlice.ts @@ -0,0 +1,29 @@ +import { createSlice } from '@reduxjs/toolkit' +import { RootState } from './store' + +export interface ThemeState { + mode: 'light' | 'dark' +} + +const initialState: ThemeState = { + mode: 'dark', +} + +export const themeSlice = createSlice({ + name: 'theme', + initialState, + reducers: { + setLightMode: (state) => { + state.mode = 'light' + }, + setDarkMode: (state) => { + state.mode = 'dark' + }, + }, +}) + +export const { setLightMode, setDarkMode } = themeSlice.actions + +export const selectMode = (state: RootState) => state.theme.mode + +export default themeSlice.reducer diff --git a/src/store/userSlice.ts b/src/store/userSlice.ts new file mode 100644 index 0000000..271ca3d --- /dev/null +++ b/src/store/userSlice.ts @@ -0,0 +1,27 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { User } from '../api/types' +import { RootState } from './store' + +export interface UserState { + user: User | null +} + +const initialState: UserState = { + user: null, +} + +export const userSlice = createSlice({ + name: 'user', + initialState, + reducers: { + setUser: (state, action: PayloadAction) => { + state.user = action.payload + }, + }, +}) + +export const { setUser } = userSlice.actions + +export const selectUser = (state: RootState) => state.user.user + +export default userSlice.reducer diff --git a/src/ui/colors.ts b/src/ui/colors.ts new file mode 100644 index 0000000..8d77c98 --- /dev/null +++ b/src/ui/colors.ts @@ -0,0 +1,5 @@ +export const GREEN = 'rgba(151, 215, 0, 0.49)' +export const YELLOW = 'rgba(254, 223, 3, 0.4)' +export const ORANGE = 'rgba(255, 104, 0, 0.5)' +export const BLUE = 'rgba(25, 123, 189, 0.46)' +export const PURPLE = 'rgba(199, 35, 177, 0.49)' diff --git a/src/ui/components/Card.tsx b/src/ui/components/Card.tsx new file mode 100644 index 0000000..83921af --- /dev/null +++ b/src/ui/components/Card.tsx @@ -0,0 +1,7 @@ +import styled from 'styled-components' +import { Theme } from './Theme' + +export const Card = styled.div` + background-color: ${({ theme }: { theme: Theme }) => theme.cardBackground}; + box-shadow: ${({ theme }: { theme: Theme }) => theme.boxShadow}; +` diff --git a/src/ui/components/Chip.tsx b/src/ui/components/Chip.tsx new file mode 100644 index 0000000..e3532bb --- /dev/null +++ b/src/ui/components/Chip.tsx @@ -0,0 +1,32 @@ +import styled from 'styled-components' +import { BLUE, GREEN, ORANGE, PURPLE, YELLOW } from '../colors' + +type ChipProps = { label: string } + +export default function Chip(props: ChipProps) { + return ( + + {props.label} + + ) +} + +const ChipContainer = styled.div` + display: flex; + background-color: ${(props) => tagToColor[props.label]}; + border-radius: 2rem; + padding: 1rem; + font-weight: bold; +` + +interface TagToColor { + [label: string]: string +} + +const tagToColor: TagToColor = { + Groceries: GREEN, + Restaurant: YELLOW, + Income: ORANGE, + Gas: BLUE, + Investment: PURPLE, +} diff --git a/src/ui/components/DatePicker.tsx b/src/ui/components/DatePicker.tsx new file mode 100644 index 0000000..ec2ad59 --- /dev/null +++ b/src/ui/components/DatePicker.tsx @@ -0,0 +1,29 @@ +import DateFnsUtils from '@date-io/date-fns' +import { KeyboardDatePicker, MuiPickersUtilsProvider } from '@material-ui/pickers' +import { MaterialUiPickersDate } from '@material-ui/pickers/typings/date' +import 'date-fns' +import React from 'react' + +type Props = { value: Date | null; onChange: (date: MaterialUiPickersDate) => void } +export default function DatePicker(props: Props) { + return ( + + + + ) +} diff --git a/src/ui/components/EmojiPicker.tsx b/src/ui/components/EmojiPicker.tsx new file mode 100644 index 0000000..00bb54d --- /dev/null +++ b/src/ui/components/EmojiPicker.tsx @@ -0,0 +1,20 @@ +import { BaseEmoji, Picker } from 'emoji-mart' +import 'emoji-mart/css/emoji-mart.css' +import { useAppSelector } from '../../store/hooks' +import { selectMode } from '../../store/themeSlice' + +type Props = { onClick: (emoji: BaseEmoji, event: React.MouseEvent) => void } + +export default function EmojiPicker(props: Props) { + const theme = useAppSelector(selectMode) + + return ( + + ) +} diff --git a/src/ui/components/GlobalStyles.tsx b/src/ui/components/GlobalStyles.tsx new file mode 100644 index 0000000..e52fafd --- /dev/null +++ b/src/ui/components/GlobalStyles.tsx @@ -0,0 +1,122 @@ +import { createGlobalStyle } from 'styled-components' +import { Theme } from './Theme' + +export const GlobalStyle = createGlobalStyle` +:root { + --shadow-md: 0 2px 4px 0 rgb(12 0 46 / 4%); + --shadow-lg: 0 10px 14px 0 rgb(12 0 46 / 6%); + + --z-sticky: 7777; + --z-navbar: 8888; + --z-drawer: 9999; + --z-modal: 9999; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body, +h1, +h2, +h3, +h4, +p, +figure, +blockquote, +dl, +dd { + margin: 0; +} + +ul[role='list'], +ol[role='list'] { + list-style: none; +} + +html:focus-within { + scroll-behavior: smooth; +} + +html { + -webkit-font-smoothing: antialiased; + touch-action: manipulation; + text-rendering: optimizelegibility; + text-size-adjust: 100%; + font-size: 62.5%; + + @media (max-width: 37.5em) { + font-size: 50%; + } + + @media (max-width: 48.0625em) { + font-size: 55%; + } + + @media (max-width: 56.25em) { + font-size: 60%; + } +} + +/* Set core body defaults */ + + +body { + min-height: 100vh; + line-height: 1.5; + font-family: var(--font); + color: ${({ theme }: { theme: Theme }) => theme.text}; + background-color: ${({ theme }: { theme: Theme }) => theme.background}; +} + +.background { + background-color: ${({ theme }: { theme: Theme }) => theme.background}; +} + +.modal { + background-color: ${({ theme }: { theme: Theme }) => theme.modalBackground}; +} + +.alert { + color: ${({ theme }: { theme: Theme }) => theme.alertColor}; +} + +svg { + color: ${({ theme }: { theme: Theme }) => theme.text}; +} + +/* A elements that don't have a class get default styles */ +a:not([class]) { + text-decoration-skip-ink: auto; +} + +img, +picture { + max-width: 100%; + display: block; +} + +input, +button, +textarea, +select { + font: inherit; +} + +@media (prefers-reduced-motion: reduce) { + html:focus-within { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + +}` diff --git a/src/ui/components/Logo.tsx b/src/ui/components/Logo.tsx new file mode 100644 index 0000000..ef9ef16 --- /dev/null +++ b/src/ui/components/Logo.tsx @@ -0,0 +1,10 @@ +import CommBank from '../assets/images/commbank.svg' + +type LogoProps = { + height: number + width: number +} + +export default function Logo(props: LogoProps) { + return +} diff --git a/src/ui/components/SectionHeading.tsx b/src/ui/components/SectionHeading.tsx new file mode 100644 index 0000000..82b2535 --- /dev/null +++ b/src/ui/components/SectionHeading.tsx @@ -0,0 +1,7 @@ +import styled from 'styled-components' + +export const SectionHeading = styled.h1` + font-weight: bold; + font-family: Roboto; + font-size: 3rem; +` diff --git a/src/ui/components/Theme.tsx b/src/ui/components/Theme.tsx new file mode 100644 index 0000000..7cafa2a --- /dev/null +++ b/src/ui/components/Theme.tsx @@ -0,0 +1,62 @@ +export type Theme = { + background: string + secondBackground: string + text: string + textSecondary: string + primary: string + secondary: string + tertiary: string + cardBackground: string + inputBackground: string + navbarBackground: string + modalBackground: string + errorColor: string + logoColor: string + alertColor: string + boxShadow: string + overlay: string +} + +export const LightTheme: Theme = { + background: 'rgb(251,251,253)', + secondBackground: 'rgb(255,255,255)', + text: 'rgb(10,18,30)', + textSecondary: 'rgb(255,255,255)', + primary: 'rgb(22,115,255)', + secondary: 'rgb(10,18,30)', + tertiary: 'rgb(231,241,251)', + cardBackground: 'hsla(0, 100%, 100%, 0.09)', + inputBackground: 'rgb(255,255,255)', + navbarBackground: 'rgb(255,255,255)', + modalBackground: 'rgb(251,251,253)', + errorColor: 'rgb(207,34,46)', + logoColor: '#000', + alertColor: 'rgba(155, 0, 50, 1)', + boxShadow: ` + 0px 12px 17px 2px hsla(0,0%,0%,0.14), + 0px 5px 22px 4px hsla(0,0%,0%,0.12), + 0px 7px 8px -4px hsla(0,0%,0%,0.2);`, + overlay: 'rgba(0,0,0,0.4)', +} + +export const DarkTheme: Theme = { + background: 'rgba(18, 18, 18, 1)', + secondBackground: 'rgb(45,55,72)', + text: 'rgb(237,237,238)', + textSecondary: 'rgb(255,255,255)', + primary: '22,115,255', + secondary: 'rgb(10,18,30)', + tertiary: 'rgb(231,241,251)', + cardBackground: 'hsla(0, 100%, 100%, 0.09)', + inputBackground: 'rgb(45,55,72)', + navbarBackground: 'rgb(45,55,72)', + modalBackground: 'rgb(39, 39, 39)', + errorColor: 'rgb(207,34,46)', + logoColor: '#fff', + alertColor: 'rgba(155, 0, 50, 1)', + boxShadow: ` + 0px 12px 17px 2px hsla(0,0%,0%,0.14), + 0px 5px 22px 4px hsla(0,0%,0%,0.12), + 0px 7px 8px -4px hsla(0,0%,0%,0.2);`, + overlay: 'rgba(0,0,0,0.7)', +} diff --git a/src/ui/components/TransparentButton.tsx b/src/ui/components/TransparentButton.tsx new file mode 100644 index 0000000..561b504 --- /dev/null +++ b/src/ui/components/TransparentButton.tsx @@ -0,0 +1,10 @@ +import styled from 'styled-components' + +export const TransparentButton = styled.button` + background-color: transparent; + outline: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; +` diff --git a/src/ui/features/goalmanager/AddIconButton.tsx b/src/ui/features/goalmanager/AddIconButton.tsx new file mode 100644 index 0000000..d0c8c2c --- /dev/null +++ b/src/ui/features/goalmanager/AddIconButton.tsx @@ -0,0 +1,31 @@ +import { faSmile } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import 'date-fns' +import React from 'react' +import styled from 'styled-components' +import { TransparentButton } from '../../components/TransparentButton' + +type Props = { hasIcon: boolean; onClick: (event: React.MouseEvent) => void } + +export default function AddIconButton(props: Props) { + if (props.hasIcon) return null + + return ( + + + + Add icon + + + ) +} + +const Container = styled.div` + flex-direction: row; + align-items: flex-end; +` +const Text = styled.span` + margin-left: 0.6rem; + font-size: 1.5rem; + color: rgba(174, 174, 174, 1); +` diff --git a/src/ui/features/goalmanager/GoalIcon.tsx b/src/ui/features/goalmanager/GoalIcon.tsx new file mode 100644 index 0000000..b5a0d75 --- /dev/null +++ b/src/ui/features/goalmanager/GoalIcon.tsx @@ -0,0 +1,19 @@ +import 'date-fns' +import React from 'react' +import styled from 'styled-components' +import { TransparentButton } from '../../components/TransparentButton' + +type Props = { icon: string | null; onClick: (e: React.MouseEvent) => void } + +export default function GoalIcon(props: Props) { + return ( + + {props.icon} + + ) +} + +const Icon = styled.h1` + font-size: 6rem; + cursor: pointer; +` diff --git a/src/ui/features/goalmanager/GoalManager.tsx b/src/ui/features/goalmanager/GoalManager.tsx new file mode 100644 index 0000000..5ad0208 --- /dev/null +++ b/src/ui/features/goalmanager/GoalManager.tsx @@ -0,0 +1,278 @@ +import { faCalendarAlt, faSmile } from '@fortawesome/free-regular-svg-icons' +import { faDollarSign, IconDefinition } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { MaterialUiPickersDate } from '@material-ui/pickers/typings/date' +import { BaseEmoji } from 'emoji-mart' +import 'date-fns' +import React, { useEffect, useState } from 'react' +import styled from 'styled-components' +import { updateGoal as updateGoalApi } from '../../../api/lib' +import { Goal } from '../../../api/types' +import { selectGoalsMap, updateGoal as updateGoalRedux } from '../../../store/goalsSlice' +import { useAppDispatch, useAppSelector } from '../../../store/hooks' +import DatePicker from '../../components/DatePicker' +import EmojiPicker from '../../components/EmojiPicker' +import { Theme } from '../../components/Theme' + +type Props = { goal: Goal } + +export function GoalManager(props: Props) { + const dispatch = useAppDispatch() + const goal = useAppSelector(selectGoalsMap)[props.goal.id] + + const [name, setName] = useState(null) + const [targetDate, setTargetDate] = useState(null) + const [targetAmount, setTargetAmount] = useState(null) + const [icon, setIcon] = useState(null) + const [emojiPickerIsOpen, setEmojiPickerIsOpen] = useState(false) + + useEffect(() => { + setName(props.goal.name) + setTargetDate(props.goal.targetDate) + setTargetAmount(props.goal.targetAmount) + setIcon(props.goal.icon) + }, [props.goal.id, props.goal.name, props.goal.targetDate, props.goal.targetAmount, props.goal.icon]) + + useEffect(() => { + setName(goal.name) + }, [goal.name]) + + const hasIcon = () => icon != null && icon !== '' + + const addIconOnClick = (event: React.MouseEvent) => { + event.stopPropagation() + setEmojiPickerIsOpen(true) + } + + const pickEmojiOnClick = (emoji: BaseEmoji, event: React.MouseEvent) => { + event.stopPropagation() + + setIcon(emoji.native) + setEmojiPickerIsOpen(false) + + const updatedGoal: Goal = { + ...props.goal, + icon: emoji.native ?? props.goal.icon, + name: name ?? props.goal.name, + targetDate: targetDate ?? props.goal.targetDate, + targetAmount: targetAmount ?? props.goal.targetAmount, + } + + dispatch(updateGoalRedux(updatedGoal)) + updateGoalApi(props.goal.id, updatedGoal) + } + + const updateNameOnChange = (event: React.ChangeEvent) => { + const nextName = event.target.value + setName(nextName) + + const updatedGoal: Goal = { + ...props.goal, + icon: icon ?? props.goal.icon, + name: nextName, + } + + dispatch(updateGoalRedux(updatedGoal)) + updateGoalApi(props.goal.id, updatedGoal) + } + + const updateTargetAmountOnChange = (event: React.ChangeEvent) => { + const nextTargetAmount = parseFloat(event.target.value) + setTargetAmount(nextTargetAmount) + + const updatedGoal: Goal = { + ...props.goal, + icon: icon ?? props.goal.icon, + name: name ?? props.goal.name, + targetDate: targetDate ?? props.goal.targetDate, + targetAmount: nextTargetAmount, + } + + dispatch(updateGoalRedux(updatedGoal)) + updateGoalApi(props.goal.id, updatedGoal) + } + + const pickDateOnChange = (date: MaterialUiPickersDate) => { + if (date != null) { + setTargetDate(date) + + const updatedGoal: Goal = { + ...props.goal, + icon: icon ?? props.goal.icon, + name: name ?? props.goal.name, + targetDate: date ?? props.goal.targetDate, + targetAmount: targetAmount ?? props.goal.targetAmount, + } + + dispatch(updateGoalRedux(updatedGoal)) + updateGoalApi(props.goal.id, updatedGoal) + } + } + + return ( + + + + + + + Add icon + + + + + {icon} + + + event.stopPropagation()} + > + + + + + + + + + + + + + + + + + + + + + {props.goal.balance} + + + + + + + {new Date(props.goal.created).toLocaleDateString()} + + + + ) +} + +type FieldProps = { name: string; icon: IconDefinition } +type AddIconButtonContainerProps = { shouldShow: boolean } +type GoalIconContainerProps = { shouldShow: boolean } +type EmojiPickerContainerProps = { isOpen: boolean; hasIcon: boolean } + +const Field = (props: FieldProps) => ( + + + {props.name} + +) + +const GoalManagerContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + height: 100%; + width: 100%; + position: relative; +` + +const AddIconButtonContainer = styled.div` + display: ${(props) => (props.shouldShow ? 'flex' : 'none')}; + margin-top: 1rem; +` + +const TransparentButton = styled.button` + display: flex; + align-items: center; + background: transparent; + border: none; + cursor: pointer; + color: ${({ theme }: { theme: Theme }) => theme.text}; +` + +const AddIconButtonText = styled.span` + font-size: 1.5rem; + margin-left: 1rem; +` + +const GoalIconContainer = styled.div` + display: ${(props) => (props.shouldShow ? 'flex' : 'none')}; + margin-top: 1rem; +` + +const GoalIcon = styled.div` + font-size: 4rem; + cursor: pointer; +` + +const EmojiPickerContainer = styled.div` + display: ${(props) => (props.isOpen ? 'flex' : 'none')}; + position: absolute; + top: ${(props) => (props.hasIcon ? '10rem' : '2rem')}; + left: 0; + z-index: 10; +` + +const Group = styled.div` + display: flex; + flex-direction: row; + width: 100%; + margin-top: 1.25rem; + margin-bottom: 1.25rem; +` + +const NameInput = styled.input` + display: flex; + background-color: transparent; + outline: none; + border: none; + font-size: 4rem; + font-weight: bold; + color: ${({ theme }: { theme: Theme }) => theme.text}; +` + +const FieldName = styled.h1` + font-size: 1.8rem; + margin-left: 1rem; + color: rgba(174, 174, 174, 1); + font-weight: normal; +` + +const FieldContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + width: 20rem; + + svg { + color: rgba(174, 174, 174, 1); + } +` + +const StringValue = styled.h1` + font-size: 1.8rem; + font-weight: bold; +` + +const StringInput = styled.input` + display: flex; + background-color: transparent; + outline: none; + border: none; + font-size: 1.8rem; + font-weight: bold; + color: ${({ theme }: { theme: Theme }) => theme.text}; +` + +const Value = styled.div` + margin-left: 2rem; +` \ No newline at end of file diff --git a/src/ui/features/themeswitcher/ThemeSwitcher.tsx b/src/ui/features/themeswitcher/ThemeSwitcher.tsx new file mode 100644 index 0000000..556518f --- /dev/null +++ b/src/ui/features/themeswitcher/ThemeSwitcher.tsx @@ -0,0 +1,25 @@ +import { faMoon, faSun } from '@fortawesome/free-regular-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import React from 'react' +import { useAppDispatch, useAppSelector } from '../../../store/hooks' +import { + selectMode, + setDarkMode as setDarkModeRedux, + setLightMode as setLightModeRedux, +} from '../../../store/themeSlice' + +export default function ThemeSwitcher() { + const mode = useAppSelector(selectMode) + const dispatch = useAppDispatch() + + const onClick = () => { + if (mode === 'light') dispatch(setDarkModeRedux()) + else dispatch(setLightModeRedux()) + } + + return ( +
+ +
+ ) +} diff --git a/src/ui/pages/Main/Main.tsx b/src/ui/pages/Main/Main.tsx new file mode 100644 index 0000000..714f091 --- /dev/null +++ b/src/ui/pages/Main/Main.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import styled from 'styled-components' +import Drawer from '../../surfaces/drawer/Drawer' +import Navbar from '../../surfaces/navbar/Navbar' +import { media } from '../../utils/media' +import AccountsSection from './accounts/AccountsSection' +import GoalsSection from './goals/GoalsSection' +import TransactionsSection from './transactions/TransactionsSection' + +export default function Main() { + return ( + + + + + + + + + + + + + + + + ) +} + +const Container = styled.div` + display: flex; + flex-direction: row; + width: 100vw; + height: 100vh; + background-color: rgb(var(--background)); + overflow: hidden; +` + +const MainSection = styled.div` + display: flex; + flex-direction: column; + width: calc(100% - 250px); + height: 100%; + + ${media('<=tablet')} { + width: 100%; + overflow: scroll; + } +` + +const Content = styled.div` + display: flex; + flex-direction: row; + width: 100%; + height: 100%; + justify-content: space-around; + align-items: center; + + ${media(' + + Accounts + + + + ) +} + +const Container = styled(Card)` + display: flex; + flex-direction: column; + justify-content: center; + padding: 4rem 2rem; + width: 400px; + height: 300px; + border-radius: 2rem; + margin-top: 2rem; + margin-bottom: 2rem; + + ${media(' { + event.stopPropagation() + dispatch(setContentRedux(goal)) + dispatch(setTypeRedux('Goal')) + dispatch(setIsOpenRedux(true)) + } + + const asLocaleDateString = (date: Date) => new Date(date).toLocaleDateString() + + return ( + + ${goal.targetAmount} + {asLocaleDateString(goal.targetDate)} + + {/* Goal Icon */} + {goal.icon} + + ) +} + +const Container = styled(Card)` + display: flex; + flex-direction: column; + min-height: 140px; + min-width: 140px; + width: 33%; + cursor: pointer; + margin-left: 2rem; + margin-right: 2rem; + border-radius: 2rem; + + align-items: center; +` + +const TargetAmount = styled.h2` + font-size: 2rem; +` + +const TargetDate = styled.h4` + color: rgba(174, 174, 174, 1); + font-size: 1rem; +` + +const Icon = styled.div` + font-size: 2rem; + margin-top: 0.5rem; +` \ No newline at end of file diff --git a/src/ui/pages/Main/goals/GoalsContent.tsx b/src/ui/pages/Main/goals/GoalsContent.tsx new file mode 100644 index 0000000..75c1f7f --- /dev/null +++ b/src/ui/pages/Main/goals/GoalsContent.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import styled from 'styled-components' +import { media } from '../../../utils/media' +import GoalCard from './GoalCard' + +type Props = { ids: string[] | null } + +export default function GoalsContent(props: Props) { + if (!props.ids) return null + + return ( + + {props.ids.map((id) => ( + + ))} + + ) +} + +const Container = styled.div` + display: flex; + flex-direction: row; + justify-content: flex-start; + width: 400px; + padding: 4rem; + overflow-x: auto; + + ${media(' { + async function fetch() { + const goals = await getGoals() + goals?.forEach((goal) => dispatch(createGoalRedux(goal))) + } + fetch() + }, [dispatch]) + + const onClick = async () => { + const goal = await createGoalApi() + + if (goal != null) { + dispatch(createGoalRedux(goal)) + dispatch(setContentRedux(goal)) + dispatch(setTypeRedux('Goal')) + dispatch(setIsOpenRedux(true)) + } + } + + return ( + + + Goals + + + + + + + + ) +} + +const Container = styled.div` + display: flex; + flex-direction: column; + width: 400px; + margin-top: 2rem; + margin-bottom: 2rem; + + ${media('(null) + + useEffect(() => { + async function fetch(tagId: string): Promise { + const response = await axios.get(`${API_ROOT}/api/Tag/${tagId}`) + return response.data + } + + async function fetchAll() { + const tags: Tag[] = [] + for (const tagId of props.transaction.tagIds) { + const tag = await fetch(tagId) + tags.push(tag) + } + + setTags(tags) + } + + fetchAll() + }) + + return ( + + +
{props.transaction.description}
+ + {tags ? tags.map((tag) => ) : null} + +
{`${new Date( + props.transaction.dateTime, + ).toLocaleDateString()}`}
+ +
{`${ + props.transaction.transactionType === 'Credit' + ? `$${props.transaction.amount}` + : `-$${props.transaction.amount}` + }`}
+
+ +
+ ) +} + +const Container = styled.div` + display: flex; + flex-direction: column; +` + +const Divider = styled.div` + width: 100%; + height: 0.2px; + background-color: rgba(174, 174, 174, 0.6); +` + +const Content = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + h6 { + font-size: 1.2rem; + } + + h6.datetime { + color: rgba(174, 174, 174, 1); + font-weight: bold; + } +` diff --git a/src/ui/pages/Main/transactions/TransactionsContent.tsx b/src/ui/pages/Main/transactions/TransactionsContent.tsx new file mode 100644 index 0000000..d898517 --- /dev/null +++ b/src/ui/pages/Main/transactions/TransactionsContent.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { Transaction } from '../../../../api/types' +import { TransactionItem } from './TransactionItem' + +type Props = { transactions: Transaction[] | null } + +export default function TransactionsContent(props: Props) { + if (!props.transactions) return null + return ( + <> + {props.transactions.sort(sortByDateDesc).map((transaction) => ( + + ))} + + ) +} + +function sortByDateDesc(a: Transaction, b: Transaction) { + return new Date(b.dateTime).getTime() - new Date(a.dateTime).getTime() +} diff --git a/src/ui/pages/Main/transactions/TransactionsSection.tsx b/src/ui/pages/Main/transactions/TransactionsSection.tsx new file mode 100644 index 0000000..06b5530 --- /dev/null +++ b/src/ui/pages/Main/transactions/TransactionsSection.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useState } from 'react' +import styled from 'styled-components' +import { getTransactions as getTransactionsApi } from '../../../../api/lib' +import { Transaction } from '../../../../api/types' +import { Card } from '../../../components/Card' +import { SectionHeading } from '../../../components/SectionHeading' +import { TransparentButton } from '../../../components/TransparentButton' +import { media } from '../../../utils/media' +import TransactionsContent from './TransactionsContent' + +export default function TransactionsSection() { + const [transactions, setTransactions] = useState(null) + + useEffect(() => { + async function fetch() { + const response = await getTransactionsApi() + + if (response !== null) { + setTransactions(response) + } + } + + fetch() + }, []) + + return ( + + + Recent Transactions + + +

See All

+
+
+ + +
+ ) +} + +const Container = styled(Card)` + display: flex; + flex-direction: column; + width: 400px; + min-height: 400px; + height: 80%; + padding: 4rem 2rem; + overflow-y: hidden; + border-radius: 2rem; + margin-top: 2rem; + margin-bottom: 2rem; + + ${media(' +
+ + + + + + + Dashboard + + + + + Goals + +
+ +
+ + + Settings + +
+ + ) +} + +const DrawerContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + overflow: hidden; + + padding: 1.5rem 1.5rem; + width: 250px; + height: 100%; + background-color: rgb(var(--primary)); + z-index: 2; + + box-shadow: ${({ theme }) => theme.boxShadow}; + + ${media('` + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + padding: 1rem; + width: 100%; + border-radius: 12px; + margin-top: 4rem; + background-color: ${(props) => (props.isSelected ? 'rgba(254, 223, 3, 0.4)' : '')}; + color: ${(props) => (props.isSelected ? '' : 'rgba(174, 174, 174, 1)')}; + font-weight: ${(props) => (props.isSelected ? 'bold' : 'light')}; + span { + font-size: 2rem; + margin-left: 1rem; + } + + svg { + color: ${(props) => (props.isSelected ? '' : 'rgba(174, 174, 174, 1)')}; + } +` + +const LogoWrapper = styled.a` + display: flex; + margin-right: auto; + text-decoration: none; + flex-direction: row; + justify-content: center; + width: 100%; + + margin-bottom: 10rem; +` + +const Logo = styled.img` + width: 100px; + height: 100px; + ${media(' { + switch (type) { + case 'Goal': + return + } + } + + if (!isOpen || content === null) return null + + const onClick = (event: React.MouseEvent) => event.stopPropagation() + + return {renderSwitch()} +} + +export const Container = styled.div` + width: 85%; + max-width: 1000px; + height: 85%; + max-width: 1000px; + background-color: ${({ theme }) => theme.modalBackground}; + border-radius: 2rem; + padding: 8rem; + z-index: 100; +` diff --git a/src/ui/surfaces/navbar/Navbar.tsx b/src/ui/surfaces/navbar/Navbar.tsx new file mode 100644 index 0000000..35c8358 --- /dev/null +++ b/src/ui/surfaces/navbar/Navbar.tsx @@ -0,0 +1,88 @@ +import { faBell, faEnvelope } from '@fortawesome/free-regular-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import React from 'react' +import styled from 'styled-components' +import Tag from '../../../assets/images/tag.png' +import { useAppSelector } from '../../../store/hooks' +import { selectUser } from '../../../store/userSlice' +import ThemeSwitcher from '../../features/themeswitcher/ThemeSwitcher' +import { media } from '../../utils/media' + +export default function Navbar() { + const user = useAppSelector(selectUser) + + return ( + + + + + + + + + + + + + + + + + + + + + {user?.name} + {user?.email} + + + + + ) +} + +const Container = styled.div` + display: flex; + flex-direction: row; + justify-content: flex-end; + position: sticky; + top: 0; + padding: 1.5rem 1.5rem; + width: 100%; + height: 8rem; +` + +const NavbarActions = styled.div` + display: flex; + flex-direction: row; + justify-content: space-around; + align-items: center; +` + +const NavbarAction = styled.a` + margin: 0.8rem; +` + +const Avatar = styled.img` + width: 50px; + height: 50px; + border-radius: 50%; +` + +const UserGroup = styled.div` + display: flex; + flex-direction: row; + justify-content: space-around; + align-items: center; +` + +const UserNameAndEmailGroup = styled.div` + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: flex-start; + + ${media('