From cb63b7cd4c366b909a9629d78c3aeb8c65a6253d Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Sat, 28 Nov 2020 16:33:24 +0000 Subject: [PATCH] Initial commit. It's working! --- .babelrc | 4 + .env.sample | 1 + .eslintrc.js | 96 + .github/FUNDING.yml | 2 + .github/workflows/ci.yml | 18 + .gitignore | 7 + .nvmrc | 1 + .prettierignore | 4 + .prettierrc.js | 6 + .vercelignore | 11 + Makefile | 39 + README.md | 26 + components/Budget/index.tsx | 86 + components/BudgetModal.tsx | 202 + components/Button/__snapshots__/test.tsx.snap | 11 + components/Button/index.tsx | 60 + components/Button/style.scss | 84 + components/Button/test.tsx | 21 + components/Expense/index.tsx | 81 + components/ExpenseModal.tsx | 244 + components/FilterBudgetModal.tsx | 78 + components/IconButton.tsx | 63 + components/ImportExportModal.tsx | 201 + components/Layout/Footer.scss | 79 + components/Layout/Footer.test.tsx | 17 + components/Layout/Footer.tsx | 48 + components/Layout/Header.scss | 15 + components/Layout/Header.tsx | 22 + components/Layout/Main.tsx | 50 + components/Layout/index.ts | 3 + components/Loading/index.tsx | 41 + components/Loading/style.scss | 25 + components/Loading/test.tsx | 20 + components/MonthNavigation.tsx | 66 + components/Panels/AddExpense.tsx | 215 + components/Panels/All.tsx | 126 + components/Panels/Budgets.tsx | 142 + components/Panels/Expenses.tsx | 192 + components/Panels/Login.tsx | 46 + components/Panels/Navigation.tsx | 27 + components/Panels/Settings.tsx | 169 + components/Paragraph.tsx | 13 + components/SegmentedControl.tsx | 70 + components/Subtitle.tsx | 32 + .../TextInput/__snapshots__/test.tsx.snap | 22 + components/TextInput/index.tsx | 63 + components/TextInput/style.scss | 45 + components/TextInput/test.tsx | 27 + components/Title.tsx | 32 + components/index.ts | 6 + jest.config.js | 16 + jest.setup.js | 4 + lib/constants.ts | 45 + lib/data-utils.ts | 613 + lib/types.ts | 49 + lib/utils.test.ts | 19 + lib/utils.ts | 143 + modules/auth/LoginButton.tsx | 47 + modules/auth/LogoutLink.tsx | 37 + next-env.d.ts | 2 + next.config.js | 23 + package-lock.json | 16204 ++++++++++++++++ package.json | 77 + pages/_app.tsx | 38 + pages/_document.tsx | 63 + pages/index.tsx | 35 + public/favicon.ico | Bin 0 -> 1150 bytes public/images/favicon.png | Bin 0 -> 13895 bytes public/images/logo.svg | 32 + public/images/logomark.png | Bin 0 -> 13419 bytes public/images/screenshots.png | Bin 0 -> 121131 bytes public/robots.txt | 5 + public/sitemap.xml | 1 + styles/__base.scss | 134 + styles/__reset.scss | 129 + styles/__variables.scss | 50 + styles/_common.scss | 45 + styles/main.scss | 1 + tsconfig.json | 34 + vercel.json | 24 + 80 files changed, 20829 insertions(+) create mode 100644 .babelrc create mode 100644 .env.sample create mode 100644 .eslintrc.js create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 .prettierignore create mode 100644 .prettierrc.js create mode 100644 .vercelignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 components/Budget/index.tsx create mode 100644 components/BudgetModal.tsx create mode 100644 components/Button/__snapshots__/test.tsx.snap create mode 100644 components/Button/index.tsx create mode 100644 components/Button/style.scss create mode 100644 components/Button/test.tsx create mode 100644 components/Expense/index.tsx create mode 100644 components/ExpenseModal.tsx create mode 100644 components/FilterBudgetModal.tsx create mode 100644 components/IconButton.tsx create mode 100644 components/ImportExportModal.tsx create mode 100644 components/Layout/Footer.scss create mode 100644 components/Layout/Footer.test.tsx create mode 100644 components/Layout/Footer.tsx create mode 100644 components/Layout/Header.scss create mode 100644 components/Layout/Header.tsx create mode 100644 components/Layout/Main.tsx create mode 100644 components/Layout/index.ts create mode 100644 components/Loading/index.tsx create mode 100644 components/Loading/style.scss create mode 100644 components/Loading/test.tsx create mode 100644 components/MonthNavigation.tsx create mode 100644 components/Panels/AddExpense.tsx create mode 100644 components/Panels/All.tsx create mode 100644 components/Panels/Budgets.tsx create mode 100644 components/Panels/Expenses.tsx create mode 100644 components/Panels/Login.tsx create mode 100644 components/Panels/Navigation.tsx create mode 100644 components/Panels/Settings.tsx create mode 100644 components/Paragraph.tsx create mode 100644 components/SegmentedControl.tsx create mode 100644 components/Subtitle.tsx create mode 100644 components/TextInput/__snapshots__/test.tsx.snap create mode 100644 components/TextInput/index.tsx create mode 100644 components/TextInput/style.scss create mode 100644 components/TextInput/test.tsx create mode 100644 components/Title.tsx create mode 100644 components/index.ts create mode 100644 jest.config.js create mode 100644 jest.setup.js create mode 100644 lib/constants.ts create mode 100644 lib/data-utils.ts create mode 100644 lib/types.ts create mode 100644 lib/utils.test.ts create mode 100644 lib/utils.ts create mode 100644 modules/auth/LoginButton.tsx create mode 100644 modules/auth/LogoutLink.tsx create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pages/_app.tsx create mode 100644 pages/_document.tsx create mode 100644 pages/index.tsx create mode 100644 public/favicon.ico create mode 100644 public/images/favicon.png create mode 100644 public/images/logo.svg create mode 100644 public/images/logomark.png create mode 100644 public/images/screenshots.png create mode 100644 public/robots.txt create mode 100644 public/sitemap.xml create mode 100644 styles/__base.scss create mode 100644 styles/__reset.scss create mode 100644 styles/__variables.scss create mode 100644 styles/_common.scss create mode 100644 styles/main.scss create mode 100644 tsconfig.json create mode 100644 vercel.json 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(