diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..854cb73 --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"], + "plugins": [["styled-components", { "ssr": true }]] +} diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..b4181c1 --- /dev/null +++ b/.env.sample @@ -0,0 +1 @@ +NODE_PATH=/ diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..428ca8c --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,96 @@ +const eslint = { + parser: '@typescript-eslint/parser', + extends: [ + 'airbnb', + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier/@typescript-eslint', + ], + rules: { + semi: 2, + 'max-len': 'off', + 'react/react-in-jsx-scope': 'off', + 'react/jsx-no-bind': 'off', + 'react/jsx-one-expression-per-line': 'off', + 'react/destructuring-assignment': 'off', + 'react/forbid-prop-types': 'off', + 'react/prefer-stateless-function': 'off', + 'react/no-danger': 'off', + 'no-console': 'off', + 'no-param-reassign': 'off', + 'import/prefer-default-export': 'off', + 'import/no-extraneous-dependencies': 'off', + 'implicit-arrow-linebreak': 'off', + 'object-curly-newline': 'off', + 'react/jsx-closing-tag-location': 'off', + 'no-restricted-syntax': 'off', + 'operator-linebreak': 'off', + 'arrow-body-style': 'off', + 'jsx-a11y/anchor-is-valid': 'off', + 'react/jsx-filename-extension': [ + 1, + { extensions: ['.js', '.jsx', '.ts', '.tsx'] }, + ], + 'no-underscore-dangle': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + 'react/jsx-props-no-spreading': 'off', + 'jsx-a11y/html-has-lang': 'off', + 'spaced-comment': 'off', + '@typescript-eslint/no-empty-interface': 'off', + 'react/no-array-index-key': 'off', + 'jsx-a11y/no-noninteractive-element-interactions': 'off', + 'jsx-a11y/click-events-have-key-events': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'import/first': 'off', + '@typescript-eslint/camelcase': 'off', + '@typescript-eslint/ban-ts-ignore': 'off', + 'jsx-a11y/label-has-associated-control': 'off', + 'no-await-in-loop': 'off', + 'react/no-did-update-set-state': 'off', + 'no-continue': 'off', + 'react/no-unescaped-entities': 'off', + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': ['error'], + '@typescript-eslint/ban-ts-comment': 'off', + 'react/require-default-props': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + 'import/extensions': [ + 'error', + 'ignorePackages', + { + js: 'never', + jsx: 'never', + ts: 'never', + tsx: 'never', + }, + ], + 'function-paren-newline': 'off', + 'no-confusing-arrow': 'off', + 'react/jsx-curly-newline': 'off', + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + useJSXTextNode: true, + extraFileExtensions: ['.ts', '.tsx'], + }, + settings: { + 'import/resolver': { + node: { + paths: ['.'], + extensions: ['.js', '.jsx', '.ts', '.tsx'], + }, + }, + }, + plugins: ['react', '@typescript-eslint'], + env: { + browser: true, + node: true, + jest: true, + }, +}; + +module.exports = eslint; diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..1013541 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [BrunoBernardino] +custom: ["https://paypal.me/brunobernardino", "https://gist.github.com/BrunoBernardino/ff5b54c13dd96ac7f9fee6fbfd825b09"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..834d9a7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: Run Tests + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: actions/setup-node@v1 + with: + node-version: 10.x + - run: | + make install + - run: | + make test/ci + env: + CI: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68c9380 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +node_modules +*.log +.env +.next +.env.build +.vercel diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..59db31c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v12.16.1 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..cce831b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +node_modules +.next +package-lock.json +.vercel diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..6fe40d5 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,6 @@ +module.exports = { + trailingComma: 'all', + tabWidth: 2, + singleQuote: true, + arrowParens: 'always', +}; diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..9542ad2 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,11 @@ +.git +.github +.next +node_modules +*.log +README.md +Makefile +*.test.js +*.test.jsx +*.test.ts +*.test.tsx diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7b8dcae --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +.PHONY: install +install: + -cp -n .env.sample .env + npm install + +.PHONY: start +start: + npm run dev + +.PHONY: test +test: + make lint + npm run test + +.PHONY: test/update +test/update: + make lint + npm run test -- -u + +.PHONY: test/pretty +test/pretty: + npm run pretty/test + +.PHONY: test/ci +test/ci: + make test/pretty + make test + +.PHONY: lint +lint: + npm run lint + +.PHONY: pretty +pretty: + npm run pretty + +.PHONY: deploy +deploy: + vercel --prod diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbaaf4c --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Budget Zen - App + +[![](https://github.com/BrunoBernardino/budgetzen-app/workflows/Run%20Tests/badge.svg)](https://github.com/BrunoBernardino/budgetzen-app/actions?workflow=Run+Tests) + +This is the web app for the [Budget Zen app](https://budgetzen.net), built with Next.js and deployed with Vercel. + +It runs completely in the browser, using `localStorage` and `IndexedDB`. + +It's not thoroughly tested just yet, so it's available but not announced. + +## Development + +```bash +make install # installs dependencies +make start # starts the app +make pretty # prettifies the code +make test # runs linting and tests +make deploy # deploys to app.budgetzen.net (requires `vercel` to be installed globally) +``` + +## TODOs + +- [ ] Improve initial screen +- [ ] Improve UI/UX in general +- [ ] Improve dark/light mode +- [ ] Improve mobile view (collapse panels and show tab bar to navigate between them?) diff --git a/components/Budget/index.tsx b/components/Budget/index.tsx new file mode 100644 index 0000000..65947ee --- /dev/null +++ b/components/Budget/index.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import styled from 'styled-components'; + +import { formatNumber } from 'lib/utils'; +import { colors, fontSizes } from 'lib/constants'; + +import * as T from 'lib/types'; + +interface BudgetProps extends T.Budget { + currency: T.Currency; + onClick: () => void; + expensesCost: number; +} + +type ContainerProps = { + isTotal: boolean; +}; + +const Container = styled.section` + display: flex; + flex: 1; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + padding: 14px 16px; + border-radius: 12px; + box-shadow: 0px 0px 4px + rgba(0, 0, 0, ${({ isTotal }) => (isTotal ? '0.2' : '0.1')}); + background-color: ${({ isTotal }) => + isTotal ? colors().secondaryBackground : colors().background}; + margin: 8px; + cursor: pointer; + min-width: 200px; + &:hover { + box-shadow: 0px 0px 4px + rgba(0, 0, 0, ${({ isTotal }) => (isTotal ? '0.5' : '0.3')}); + } +`; + +const LeftColumn = styled.div` + display: flex; + flex: 1; + flex-direction: column; +`; + +const Cost = styled.span` + color: ${colors().text}; + font-size: ${fontSizes.label}px; + font-weight: bold; + text-align: left; +`; + +const Name = styled.span` + color: ${colors().text}; + font-size: ${fontSizes.text}px; + font-weight: normal; + text-align: left; + margin-top: 6px; +`; + +const BudgetMissing = styled.div` + color: ${colors().secondaryText}; + font-size: ${fontSizes.largeText}px; + font-weight: normal; + text-align: right; +`; + +const Budget = (props: BudgetProps) => { + const budgetMissing = props.value - props.expensesCost; + return ( + + + + {formatNumber(props.currency, props.expensesCost)} of{' '} + {formatNumber(props.currency, props.value)} + + {props.name} + + + {formatNumber(props.currency, budgetMissing)} + + + ); +}; + +export default Budget; diff --git a/components/BudgetModal.tsx b/components/BudgetModal.tsx new file mode 100644 index 0000000..b474cbc --- /dev/null +++ b/components/BudgetModal.tsx @@ -0,0 +1,202 @@ +import React, { useState, useCallback } from 'react'; +import styled from 'styled-components'; +import Rodal from 'rodal'; +import Swal from 'sweetalert2'; +import { RxDatabase } from 'rxdb'; + +import Button from 'components/Button'; +import { showNotification } from 'lib/utils'; +import { saveBudget, deleteBudget } from 'lib/data-utils'; +import { colors, fontSizes } from 'lib/constants'; +import * as T from 'lib/types'; + +interface BudgetModalProps { + isOpen: boolean; + onClose: () => void; + id: string; + name: string; + month: string; + value: number; + reloadData: () => Promise; + db: RxDatabase; +} + +const Container = styled.section` + display: flex; + flex-direction: column; + flex: 1; + background-color: ${colors().background}; + padding: 0 16px; +`; + +const Label = styled.label` + color: ${colors().inputLabel}; + font-size: ${fontSizes.inputLabel}px; + font-weight: bold; + text-align: left; + margin-top: 38px; +`; + +const Input = styled.input` + font-family: inherit; + color: ${colors().inputField}; + font-size: ${fontSizes.inputField}px; + font-weight: normal; + text-align: left; + margin-top: 8px; + background-color: ${colors().background}; + padding: 5px 8px; + border: 1px solid ${colors().secondaryBackground}; + border-radius: 5px; + outline: none; + &::-webkit-input-placeholder { + color: ${colors().inputPlaceholder}; + } + &:hover, + &:focus, + &:active { + box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.2); + } +`; + +const StyledButton = styled(Button)` + margin: 20px 0; +`; + +const BudgetModal = (props: BudgetModalProps) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [name, setName] = useState(props.name); + const [month, setMonth] = useState(`${props.month}-01`); + const [value, setValue] = useState(props.value.toString()); + + const { id, isOpen, reloadData, db } = props; + + const onClose = useCallback(() => { + const { onClose: closeModal } = props; + setName(''); + setMonth(''); + setValue(''); + closeModal(); + }, []); + + const addBudget = async () => { + if (isSubmitting) { + // Ignore sequential clicks + return; + } + + setIsSubmitting(true); + + const parsedBudget: T.Budget = { + id: id || 'newBudget', + value: Number.parseFloat(value.replace(',', '.')), + name, + month: month ? month.substr(0, 7) : '', + }; + + const success = await saveBudget(db, parsedBudget); + + setIsSubmitting(false); + + if (success) { + showNotification(`Budget ${id ? 'updated' : 'added'} successfully.`); + onClose(); + } + + await reloadData(); + }; + + const removeBudget = async () => { + if (isSubmitting) { + // Ignore sequential clicks + return; + } + + const confirmationResult = await Swal.fire({ + icon: 'warning', + title: 'Are you sure?', + text: + 'Are you sure you want to delete this budget?\n\nThis action is irreversible.', + showDenyButton: true, + showCancelButton: true, + confirmButtonText: 'Yes!', + denyButtonText: 'Nope, cancel.', + }); + + if (!confirmationResult || !confirmationResult.isConfirmed) { + return; + } + + setIsSubmitting(true); + + const success = await deleteBudget(db, id); + + setIsSubmitting(false); + + if (success) { + showNotification('Budget deleted successfully.'); + onClose(); + } + + await reloadData(); + }; + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + addBudget(); + } + }, + [], + ); + + return ( + + + + setName(event.target.value)} + value={name} + autoComplete="off" + type="text" + onKeyDown={onKeyDown} + /> + + + setValue(event.target.value)} + value={value} + autoComplete="off" + type="number" + inputMode="decimal" + onKeyDown={onKeyDown} + /> + + + setMonth(event.target.value)} + value={month} + autoComplete="off" + type="date" + onKeyDown={onKeyDown} + /> + + addBudget()} type="primary"> + {id ? 'Save Budget' : 'Add Budget'} + + + {Boolean(id) && ( + removeBudget()} type="delete"> + Delete Budget + + )} + + + ); +}; + +export default BudgetModal; diff --git a/components/Button/__snapshots__/test.tsx.snap b/components/Button/__snapshots__/test.tsx.snap new file mode 100644 index 0000000..dc5f8c9 --- /dev/null +++ b/components/Button/__snapshots__/test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Button renders the button as expected 1`] = ` + +`; diff --git a/components/Button/index.tsx b/components/Button/index.tsx new file mode 100644 index 0000000..9b6f3ad --- /dev/null +++ b/components/Button/index.tsx @@ -0,0 +1,60 @@ +import React, { forwardRef } from 'react'; +import styled from 'styled-components'; + +import './style.scss'; + +interface ButtonProps { + element?: 'button' | 'a'; + isDisabled?: boolean; + type?: 'primary' | 'secondary' | 'delete'; + href?: string; + onClick?: any; + className?: string; + width?: '' | 'large' | 'tiny'; +} + +const Button: React.FC = forwardRef( + (props: ButtonProps, ref: any) => { + const { + element, + className, + type, + isDisabled, + width, + ...remainingProps + } = props; + + if (element === 'button') { + const StyledButton = styled.button.attrs({ + className: `${className || ''} Button Button--${type} ${ + width ? `Button--${width}` : '' + }`, + })``; + return ( + + ); + } + + const StyledAnchor = styled.a.attrs({ + className: `${className || ''} Button Button--${type} ${ + width ? `Button--${width}` : '' + }`, + })``; + + return ; + }, +); + +Button.defaultProps = { + element: 'button', + isDisabled: false, + type: 'primary', + width: '', +}; + +export default Button; diff --git a/components/Button/style.scss b/components/Button/style.scss new file mode 100644 index 0000000..ddae855 --- /dev/null +++ b/components/Button/style.scss @@ -0,0 +1,84 @@ +@import '__variables'; + +$transition-speed: 140ms; + +a.Button { + display: inline-block; + padding: 1.3rem 1.8rem; + + &--large { + padding: 1.3rem 4.2rem; + } + + &--tiny { + font-size: 0.85rem; + line-height: 1.2rem; + margin: 0 auto; + text-align: center; + padding: 0.5rem 0.8rem; + font-weight: normal; + } +} + +button.Button { + display: block; + padding: 1rem 1.8rem; + + &--large { + padding: 1rem 4.2rem; + } + + &--tiny { + padding: 1rem; + } +} + +.Button { + color: $color-white; + font-size: 1.5rem; + line-height: 1.8rem; + margin: 0 auto; + text-align: center; + padding: 1.3rem 1.8rem; + border-radius: 5px; + cursor: pointer; + text-decoration: none; + font-weight: 500; + z-index: 0; + position: relative; + background-size: 100%; + transition: all $transition-speed ease-in; + border: 1px solid $color-background; + + &:focus, + &:hover { + color: $color-white; + box-shadow: 1px 1px 3px $color-block-border; + text-shadow: 1px 1px 1px $color-text; + outline: none; + + opacity: 0.7; + } + + &--primary { + background-color: $color-menu-background; + } + + &--secondary { + background-color: $color-background; + + @media (prefers-color-scheme: dark) { + background-color: $color-text; + } + + &:focus, + &:hover { + box-shadow: 1px 1px 3px $color-block-border; + text-shadow: 1px 1px 1px $color-block-border; + } + } + + &--delete { + background-color: $color-background-red; + } +} diff --git a/components/Button/test.tsx b/components/Button/test.tsx new file mode 100644 index 0000000..18a1776 --- /dev/null +++ b/components/Button/test.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { shallow } from 'enzyme'; +import expect from 'expect'; +import enzymify from 'expect-enzyme'; + +import Button from './index'; + +expect.extend(enzymify()); + +describe('Button', () => { + it('renders the button without errors', () => { + const wrapper = shallow(); + expect(wrapper.first().text()).toBe('Hello'); + }); + + it('renders the button as expected', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/components/Expense/index.tsx b/components/Expense/index.tsx new file mode 100644 index 0000000..7a8230f --- /dev/null +++ b/components/Expense/index.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import moment from 'moment'; +import styled from 'styled-components'; + +import { formatNumber } from 'lib/utils'; +import { colors, fontSizes } from 'lib/constants'; + +import * as T from 'lib/types'; + +interface ExpenseProps extends T.Expense { + currency: T.Currency; + onClick: () => void; +} + +const Container = styled.section` + display: flex; + flex: 1; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + padding: 14px 16px; + border-radius: 12px; + box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.1); + background-color: ${colors().background}; + margin: 8px; + cursor: pointer; + min-width: 200px; +`; + +const LeftColumn = styled.div` + display: flex; + flex: 0.5; + flex-direction: column; +`; +const Cost = styled.span` + color: ${colors().text}; + font-size: ${fontSizes.label}px; + font-weight: bold; + text-align: left; +`; +const Budget = styled.span` + color: ${colors().secondaryText}; + font-size: ${fontSizes.text}px; + font-weight: normal; + text-align: left; + margin-top: 6px; +`; +const Description = styled.div` + color: ${colors().text}; + font-size: ${fontSizes.mediumText}px; + font-weight: normal; + text-align: left; + padding: 0 6px; +`; +const Date = styled.div` + color: ${colors().secondaryText}; + font-size: ${fontSizes.largeText}px; + font-weight: normal; + text-align: right; + text-transform: uppercase; +`; + +const Expense = (props: ExpenseProps) => { + const expenseDate = moment(props.date, 'YYYY-MM-DD'); + return ( + + + {formatNumber(props.currency, props.cost)} + {props.budget} + + {props.description} + + {expenseDate.format('DD')} + {'\n'} + {expenseDate.format('MMM')} + + + ); +}; + +export default Expense; diff --git a/components/ExpenseModal.tsx b/components/ExpenseModal.tsx new file mode 100644 index 0000000..a91fb48 --- /dev/null +++ b/components/ExpenseModal.tsx @@ -0,0 +1,244 @@ +import React, { useState, useCallback } from 'react'; +import styled from 'styled-components'; +import Rodal from 'rodal'; +import Swal from 'sweetalert2'; +import { RxDatabase } from 'rxdb'; + +import Button from 'components/Button'; +import { showNotification } from 'lib/utils'; +import { saveExpense, deleteExpense } from 'lib/data-utils'; +import { colors, fontSizes } from 'lib/constants'; +import * as T from 'lib/types'; + +interface ExpenseModalProps { + isOpen: boolean; + onClose: () => void; + id: string; + cost: number; + description: string; + budget: string; + date: string; + budgets: T.Budget[]; + reloadData: () => Promise; + db: RxDatabase; +} + +const Container = styled.section` + display: flex; + flex-direction: column; + flex: 1; + background-color: ${colors().background}; + padding: 0 16px; +`; + +const Label = styled.label` + color: ${colors().inputLabel}; + font-size: ${fontSizes.inputLabel}px; + font-weight: bold; + text-align: left; + margin-top: 38px; +`; + +const Input = styled.input` + font-family: inherit; + color: ${colors().inputField}; + font-size: ${fontSizes.inputField}px; + font-weight: normal; + text-align: left; + margin-top: 8px; + background-color: ${colors().background}; + padding: 5px 8px; + border: 1px solid ${colors().secondaryBackground}; + border-radius: 5px; + outline: none; + &::-webkit-input-placeholder { + color: ${colors().inputPlaceholder}; + } + &:hover, + &:focus, + &:active { + box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.2); + } +`; + +const Select = styled.select` + color: ${colors().inputField}; + font-size: ${fontSizes.inputField}px; + font-weight: normal; + text-align: left; + margin-top: 8px; + background-color: ${colors().background}; + padding: 5px 8px; + border: 1px solid ${colors().secondaryBackground}; + border-radius: 5px; + outline: none; + &::-webkit-input-placeholder { + color: ${colors().inputPlaceholder}; + } + &:hover, + &:focus, + &:active { + box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.2); + } +`; + +const StyledButton = styled(Button)` + margin: 20px 0; +`; + +const ExpenseModal = (props: ExpenseModalProps) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [description, setDescription] = useState(props.description); + const [cost, setCost] = useState(props.cost.toString()); + const [budget, setBudget] = useState(props.budget); + const [date, setDate] = useState(props.date); + + const { id, isOpen, budgets, reloadData, db } = props; + + const onClose = useCallback(() => { + const { onClose: closeModal } = props; + setDescription(''); + setCost(''); + setBudget(''); + setDate(''); + closeModal(); + }, []); + + const addExpense = async () => { + if (isSubmitting) { + // Ignore sequential clicks + return; + } + + setIsSubmitting(true); + + const parsedExpense: T.Expense = { + id: id || 'newExpense', + description, + cost: Number.parseFloat(cost.replace(',', '.')), + budget, + date, + }; + + const success = await saveExpense(db, parsedExpense); + + setIsSubmitting(false); + + if (success) { + showNotification(`Expense ${id ? 'updated' : 'added'} successfully.`); + onClose(); + } + + await reloadData(); + }; + + const removeExpense = async () => { + if (isSubmitting) { + // Ignore sequential clicks + return; + } + + const confirmationResult = await Swal.fire({ + icon: 'warning', + title: 'Are you sure?', + text: + 'Are you sure you want to delete this expense?\n\nThis action is irreversible.', + showDenyButton: true, + showCancelButton: true, + confirmButtonText: 'Yes!', + denyButtonText: 'Nope, cancel.', + }); + + if (!confirmationResult || !confirmationResult.isConfirmed) { + return; + } + + setIsSubmitting(true); + + const success = await deleteExpense(db, id); + + setIsSubmitting(false); + + if (success) { + showNotification('Expense deleted successfully.'); + onClose(); + } + + await reloadData(); + }; + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + addExpense(); + } + }, + [], + ); + + return ( + + + + setCost(event.target.value)} + value={cost} + autoComplete="off" + type="number" + inputMode="decimal" + onKeyDown={onKeyDown} + /> + + + setDescription(event.target.value)} + value={description} + autoComplete="off" + type="text" + onKeyDown={onKeyDown} + /> + + + + + + setDate(event.target.value)} + value={date} + autoComplete="off" + type="date" + onKeyDown={onKeyDown} + /> + + addExpense()} type="primary"> + {id ? 'Save Expense' : 'Add Expense'} + + + {Boolean(id) && ( + removeExpense()} type="delete"> + Delete Expense + + )} + + + ); +}; + +export default ExpenseModal; diff --git a/components/FilterBudgetModal.tsx b/components/FilterBudgetModal.tsx new file mode 100644 index 0000000..878f0a1 --- /dev/null +++ b/components/FilterBudgetModal.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import styled from 'styled-components'; +import Rodal from 'rodal'; +import Switch from 'react-toggle-switch'; + +import { colors, fontSizes } from 'lib/constants'; + +import * as T from 'lib/types'; + +interface FilterBudgetModalProps { + isOpen: boolean; + onClose: () => void; + onFilterBudgetToggle: (budgetName: string, newValue: boolean) => void; + budgets: T.Budget[]; + filterBudgets: Set; +} + +const Container = styled.section` + display: flex; + flex-direction: column; + flex: 1; + background-color: ${colors().background}; + padding: 0 16px; +`; + +const IntroText = styled.p` + color: ${colors().secondaryText}; + font-size: ${fontSizes.mediumText}; + margin-bottom: 20px; +`; + +const Budget = styled.section<{ isOdd: boolean }>` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + background-color: ${({ isOdd }) => + isOdd ? colors().alternateBackground : colors().background}; + padding: 8px; + border-radius: 10px; +`; + +const BudgetName = styled.span` + font-size: ${fontSizes.label}; +`; + +const FilterBudgetModal = (props: FilterBudgetModalProps) => { + const { + isOpen, + onClose, + budgets, + onFilterBudgetToggle, + filterBudgets, + } = props; + return ( + + + Choose which budgets to filter by: + {budgets.map((budget, index) => ( + + {budget.name} + + onFilterBudgetToggle( + budget.name, + !filterBudgets.has(budget.name), + ) + } + /> + + ))} + + + ); +}; + +export default FilterBudgetModal; diff --git a/components/IconButton.tsx b/components/IconButton.tsx new file mode 100644 index 0000000..0f34da6 --- /dev/null +++ b/components/IconButton.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { IonIcon, addIcons } from 'react-svg-ionicons'; +import styled from 'styled-components'; +import settings from 'react-svg-ionicons/icons/settings'; +import options from 'react-svg-ionicons/icons/options'; +import arrowBack from 'react-svg-ionicons/icons/arrow-back'; +import arrowForward from 'react-svg-ionicons/icons/arrow-forward'; + +interface IconButtonProps { + icon: 'settings' | 'options' | 'arrow-back' | 'arrow-forward'; + size: number; + color: string; + onClick: () => void; + className?: string; +} + +const bundle = { + settings, + options, + 'arrow-back': arrowBack, + 'arrow-forward': arrowForward, +}; +addIcons(bundle); + +const Container = styled.div` + display: flex; + flex: 1; + justify-content: center; + align-items: center; + padding: 0 10px; +`; + +const Button = styled.button` + background: transparent; + border: none; + align-items: center; + padding: 5px 10px; + cursor: pointer; + outline: none; + &:hover, + &:focus, + &:active { + opacity: 0.6; + } +`; + +const IconButton = ({ + icon, + size, + color, + className, + onClick, +}: IconButtonProps) => { + return ( + + + + ); +}; + +export default IconButton; diff --git a/components/ImportExportModal.tsx b/components/ImportExportModal.tsx new file mode 100644 index 0000000..c3fd9b2 --- /dev/null +++ b/components/ImportExportModal.tsx @@ -0,0 +1,201 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import Rodal from 'rodal'; +import Swal from 'sweetalert2'; +import { RxDatabase } from 'rxdb'; + +import Button from 'components/Button'; +import { showNotification } from 'lib/utils'; +import { exportAllData, importData } from 'lib/data-utils'; +import { colors, fontSizes } from 'lib/constants'; +import * as T from 'lib/types'; + +type ImportedFileData = { + budgets?: T.Budget[]; + expenses?: T.Expense[]; +}; + +interface ImportExportModalProps { + db: RxDatabase; + syncToken: string; + isOpen: boolean; + onClose: () => void; +} + +const Container = styled.section` + display: flex; + flex-direction: column; + flex: 1; + background-color: ${colors().background}; + padding: 0 16px; +`; + +const Label = styled.label` + color: ${colors().inputLabel}; + font-size: ${fontSizes.inputLabel}px; + font-weight: bold; + text-align: left; + margin-top: 38px; +`; + +const Note = styled.span` + color: ${colors().inputLabel}; + font-size: ${fontSizes.mediumText}px; + font-weight: normal; + text-align: left; + margin-top: 30px; +`; + +const StyledButton = styled(Button)` + margin-top: 20px; + align-self: center; +`; + +const ImportExportModal = (props: ImportExportModalProps) => { + const [isSubmitting, setIsSubmitting] = useState(false); + + const { isOpen, onClose, db, syncToken } = props; + + const onRequestImport = async () => { + if (isSubmitting) { + // Ignore sequential taps + return; + } + + const importFileDialogResult = await Swal.fire({ + icon: 'warning', + input: 'file', + title: 'Choose JSON File', + inputAttributes: { + accept: 'text/pain,application/json,.json', + 'aria-label': 'Import your budgets and expenses', + }, + }); + + if (!importFileDialogResult || !importFileDialogResult.value) { + return; + } + + const reader = new FileReader(); + reader.onload = async (fileRead) => { + const importFileContents = fileRead.target.result; + + let importedFileData: ImportedFileData = {}; + + try { + importedFileData = JSON.parse(importFileContents.toString()); + } catch (error) { + importedFileData = {}; + } + + if ( + !Object.prototype.hasOwnProperty.call(importedFileData, 'budgets') && + !Object.prototype.hasOwnProperty.call(importedFileData, 'expenses') + ) { + showNotification( + 'Could not parse the file. Please confirm what you chose is correct.', + 'error', + ); + return; + } + + const budgets = importedFileData.budgets || []; + const expenses = importedFileData.expenses || []; + + const mergeOrReplaceDialogResult = await Swal.fire({ + icon: 'question', + title: 'Merge or Replace?', + text: + 'Do you want to merge this with your existing data, or replace it?', + showCancelButton: true, + showDenyButton: true, + confirmButtonText: 'Merge', + denyButtonText: 'Replace', + cancelButtonText: 'Wait, cancel.', + }); + + if ( + mergeOrReplaceDialogResult.isConfirmed || + mergeOrReplaceDialogResult.isDenied + ) { + setIsSubmitting(true); + + const success = await importData( + db, + syncToken, + mergeOrReplaceDialogResult.isDenied, + budgets, + expenses, + ); + + setIsSubmitting(false); + + if (success) { + onClose(); + } + } + }; + + reader.readAsDataURL(importFileDialogResult.value); + }; + + const onRequestExport = async () => { + if (isSubmitting) { + // Ignore sequential taps + return; + } + + setIsSubmitting(true); + + const fileName = `data-export-${new Date() + .toISOString() + .substr(0, 19) + .replace(/:/g, '-')}.json`; + + const exportData = await exportAllData(db); + + const exportContents = JSON.stringify(exportData, null, 2); + + // Add content-type + const jsonContent = `data:application/json;charset=utf-8,${exportContents}`; + + // Download the file + const data = encodeURI(jsonContent); + const link = document.createElement('a'); + link.setAttribute('href', data); + link.setAttribute('download', fileName); + link.click(); + link.remove(); + + setIsSubmitting(false); + + showNotification('Data exported successfully!'); + }; + + return ( + + + + Import a JSON file exported from Budget Zen before. + + + Learn more + + + onRequestImport()} type="secondary"> + Import Data + + + onRequestExport()} type="primary"> + Export Data + + + + ); +}; + +export default ImportExportModal; diff --git a/components/Layout/Footer.scss b/components/Layout/Footer.scss new file mode 100644 index 0000000..af67764 --- /dev/null +++ b/components/Layout/Footer.scss @@ -0,0 +1,79 @@ +@import '__variables'; + +.Footer { + display: block; + padding: 20px 10px; + margin: 20px auto 0; + text-align: center; + background-color: #dfdfdf; + + @media (prefers-color-scheme: dark) { + background-color: #161616; + } + + &__faq { + display: block; + padding: 1em 2em 2em; + border-bottom: 1px solid #999; + + h3 { + font-size: 1.3em; + font-weight: 500; + line-height: 1.5em; + margin-bottom: 1em; + } + } + + &__faq-items { + display: block; + max-width: 100%; + margin: 0 auto; + + @media #{$bigger-screen} { + @include flex; + flex-wrap: wrap; + width: $max-width; + } + } + + &__faq-item { + display: block; + margin: 1em 1em 2em; + text-align: left; + line-height: 1.4em; + font-size: 0.9em; + + @media #{$bigger-screen} { + margin: 1em; + width: 45%; + } + + h4 { + font-size: 1.1em; + font-weight: 500; + margin-bottom: 0.2em; + } + } + + &__links { + font-size: 0.8rem; + line-height: 1rem; + font-weight: 400; + margin-top: 1.5em; + + a { + text-decoration: none; + + @media (prefers-color-scheme: dark) { + color: $color-menu-background-hover; + } + + &:hover, + &:focus { + @media (prefers-color-scheme: dark) { + color: #f3f3f3; + } + } + } + } +} diff --git a/components/Layout/Footer.test.tsx b/components/Layout/Footer.test.tsx new file mode 100644 index 0000000..b485924 --- /dev/null +++ b/components/Layout/Footer.test.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import expect from 'expect'; +import enzymify from 'expect-enzyme'; + +import Footer from './Footer'; + +expect.extend(enzymify()); + +describe('Footer', () => { + it('renders the Footer with a link', () => { + const wrapper = shallow(