From 56148b1b5185e15008974abad3887abd4ec13904 Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Wed, 20 Jul 2022 20:01:29 +0100 Subject: [PATCH] Migrate to Deno in the backend, vanilla JS in the frontend. This also tweaks the UI and UX. All data is still compatible, and there's some new functionality, like clicking on the month to navigate to any month. --- .babelrc | 13 - .dvmrc | 1 + .env.sample | 5 +- .eslintignore | 10 - .eslintrc.js | 99 - .github/workflows/ci.yml | 18 - .github/workflows/tests.yml | 14 + .gitignore | 19 - .npmrc | 1 - .nvmrc | 1 - .prettierignore | 15 - .prettierrc.js | 6 - .vscode/settings.json | 4 + Makefile | 42 +- README.md | 36 +- components/Budget/index.tsx | 86 - components/BudgetModal.tsx | 203 - components/Button/__snapshots__/test.tsx.snap | 11 - components/Button/index.tsx | 55 - components/Button/styles.module.scss | 87 - components/Button/test.tsx | 21 - components/Expense/index.tsx | 81 - components/ExpenseModal.tsx | 252 - components/FilterBudgetModal.tsx | 73 - components/IconButton.tsx | 63 - components/ImportExportModal.tsx | 204 - components/Layout/Footer.module.scss | 89 - components/Layout/Footer.test.tsx | 17 - components/Layout/Footer.tsx | 73 - components/Layout/Header.module.scss | 15 - components/Layout/Header.tsx | 22 - components/Layout/Main.tsx | 52 - components/Layout/index.ts | 3 - components/Loading/styles.module.scss | 25 - components/Loading/test.tsx | 20 - components/MonthNavigation.tsx | 66 - components/Panels/AddExpense.tsx | 227 - components/Panels/All.tsx | 183 - components/Panels/Billing.tsx | 156 - components/Panels/Budgets.tsx | 157 - components/Panels/EmailPassword.tsx | 161 - components/Panels/Expenses.tsx | 207 - components/Panels/Login.tsx | 61 - components/Panels/Navigation.tsx | 27 - components/Panels/Pricing.tsx | 117 - components/Panels/Settings.tsx | 231 - components/Paragraph.tsx | 15 - components/SegmentedControl.tsx | 70 - components/Subtitle.tsx | 32 - .../TextInput/__snapshots__/test.tsx.snap | 22 - components/TextInput/index.tsx | 56 - components/TextInput/styles.module.scss | 52 - components/TextInput/test.tsx | 27 - components/Title.tsx | 32 - components/footer.ts | 56 + components/header.ts | 31 + components/index.ts | 6 - components/{Loading/index.tsx => loading.ts} | 24 +- deno.json | 35 + jest.config.js | 16 - jest.setup.js | 4 - lib/constants.ts | 63 - lib/data-utils.ts | 751 - lib/types.ts | 41 - lib/utils.test.ts | 87 - lib/utils.ts | 284 +- lib/utils_test.ts | 104 + main.ts | 25 + main_test.ts | 25 + modules/auth/LoginButton.tsx | 124 - modules/auth/LogoutLink.tsx | 37 - next-env.d.ts | 5 - next.config.js | 53 - package-lock.json | 24762 ---------------- package.json | 76 - pages/_app.tsx | 38 - pages/_document.tsx | 60 - pages/billing.ts | 186 + pages/billing.tsx | 92 - pages/email-password.tsx | 74 - pages/index.ts | 326 + pages/index.tsx | 67 - pages/pricing.ts | 126 + pages/pricing.tsx | 84 - pages/settings.ts | 113 + public/css/style.css | 781 + public/js/index.js | 695 + public/js/script.js | 1027 + public/js/settings.js | 250 + public/js/stripe.js | 1 + public/js/sw.js | 75 + public/js/sweetalert.js | 2 + public/js/userbase.js | 26 + public/js/userbase.js.map | 2022 ++ public/manifest.json | 4 +- public/sitemap.xml | 1 - routes.ts | 172 + serverless.yml | 11 - styles/__base.scss | 158 - styles/__reset.scss | 129 - styles/__variables.scss | 51 - styles/_common.scss | 54 - styles/main.scss | 1 - tsconfig.json | 35 - 104 files changed, 6286 insertions(+), 30619 deletions(-) delete mode 100644 .babelrc create mode 100644 .dvmrc delete mode 100644 .eslintignore delete mode 100644 .eslintrc.js delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/tests.yml delete mode 100644 .npmrc delete mode 100644 .nvmrc delete mode 100644 .prettierignore delete mode 100644 .prettierrc.js create mode 100644 .vscode/settings.json delete mode 100644 components/Budget/index.tsx delete mode 100644 components/BudgetModal.tsx delete mode 100644 components/Button/__snapshots__/test.tsx.snap delete mode 100644 components/Button/index.tsx delete mode 100644 components/Button/styles.module.scss delete mode 100644 components/Button/test.tsx delete mode 100644 components/Expense/index.tsx delete mode 100644 components/ExpenseModal.tsx delete mode 100644 components/FilterBudgetModal.tsx delete mode 100644 components/IconButton.tsx delete mode 100644 components/ImportExportModal.tsx delete mode 100644 components/Layout/Footer.module.scss delete mode 100644 components/Layout/Footer.test.tsx delete mode 100644 components/Layout/Footer.tsx delete mode 100644 components/Layout/Header.module.scss delete mode 100644 components/Layout/Header.tsx delete mode 100644 components/Layout/Main.tsx delete mode 100644 components/Layout/index.ts delete mode 100644 components/Loading/styles.module.scss delete mode 100644 components/Loading/test.tsx delete mode 100644 components/MonthNavigation.tsx delete mode 100644 components/Panels/AddExpense.tsx delete mode 100644 components/Panels/All.tsx delete mode 100644 components/Panels/Billing.tsx delete mode 100644 components/Panels/Budgets.tsx delete mode 100644 components/Panels/EmailPassword.tsx delete mode 100644 components/Panels/Expenses.tsx delete mode 100644 components/Panels/Login.tsx delete mode 100644 components/Panels/Navigation.tsx delete mode 100644 components/Panels/Pricing.tsx delete mode 100644 components/Panels/Settings.tsx delete mode 100644 components/Paragraph.tsx delete mode 100644 components/SegmentedControl.tsx delete mode 100644 components/Subtitle.tsx delete mode 100644 components/TextInput/__snapshots__/test.tsx.snap delete mode 100644 components/TextInput/index.tsx delete mode 100644 components/TextInput/styles.module.scss delete mode 100644 components/TextInput/test.tsx delete mode 100644 components/Title.tsx create mode 100644 components/footer.ts create mode 100644 components/header.ts delete mode 100644 components/index.ts rename components/{Loading/index.tsx => loading.ts} (70%) create mode 100644 deno.json delete mode 100644 jest.config.js delete mode 100644 jest.setup.js delete mode 100644 lib/constants.ts delete mode 100644 lib/data-utils.ts delete mode 100644 lib/types.ts delete mode 100644 lib/utils.test.ts create mode 100644 lib/utils_test.ts create mode 100644 main.ts create mode 100644 main_test.ts delete mode 100644 modules/auth/LoginButton.tsx delete mode 100644 modules/auth/LogoutLink.tsx delete mode 100644 next-env.d.ts delete mode 100644 next.config.js delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 pages/_app.tsx delete mode 100644 pages/_document.tsx create mode 100644 pages/billing.ts delete mode 100644 pages/billing.tsx delete mode 100644 pages/email-password.tsx create mode 100644 pages/index.ts delete mode 100644 pages/index.tsx create mode 100644 pages/pricing.ts delete mode 100644 pages/pricing.tsx create mode 100644 pages/settings.ts create mode 100644 public/css/style.css create mode 100644 public/js/index.js create mode 100644 public/js/script.js create mode 100644 public/js/settings.js create mode 100644 public/js/stripe.js create mode 100644 public/js/sw.js create mode 100644 public/js/sweetalert.js create mode 100644 public/js/userbase.js create mode 100644 public/js/userbase.js.map delete mode 100644 public/sitemap.xml create mode 100644 routes.ts delete mode 100644 serverless.yml delete mode 100644 styles/__base.scss delete mode 100644 styles/__reset.scss delete mode 100644 styles/__variables.scss delete mode 100644 styles/_common.scss delete mode 100644 styles/main.scss delete mode 100644 tsconfig.json diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 14ef043..0000000 --- a/.babelrc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "presets": ["next/babel"], - "plugins": [ - [ - "styled-components", - { - "ssr": true, - "displayName": true, - "preprocess": false - } - ] - ] -} diff --git a/.dvmrc b/.dvmrc new file mode 100644 index 0000000..57807d6 --- /dev/null +++ b/.dvmrc @@ -0,0 +1 @@ +1.22.0 diff --git a/.env.sample b/.env.sample index efb69c7..8526897 100644 --- a/.env.sample +++ b/.env.sample @@ -1,4 +1 @@ -NODE_PATH=/ -AWS_ACCESS_KEY_ID=accesskey -AWS_SECRET_ACCESS_KEY=sshhh -NEXT_PUBLIC_USERBASE_APP_ID=get-from-userbase.com +USERBASE_APP_ID=get-from-userbase.com diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 35b8cf5..0000000 --- a/.eslintignore +++ /dev/null @@ -1,10 +0,0 @@ -**/public/precache.*.*.js -**/public/sw.js -**/public/workbox-*.js -**/public/worker-*.js -**/public/fallback-*.js -**/public/precache.*.*.js.map -**/public/sw.js.map -**/public/workbox-*.js.map -**/public/worker-*.js.map -**/public/fallback-*.js diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index b4a7851..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,99 +0,0 @@ -const eslint = { - parser: '@typescript-eslint/parser', - extends: [ - 'airbnb', - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'prettier', - ], - 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', - 'react/function-component-definition': 'off', - 'react/jsx-no-useless-fragment': 'off', - 'no-nested-ternary': '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/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 4426b5f..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Run Tests - -on: [push] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 - with: - node-version: 12.x - - run: | - make install - - run: | - make test/ci - env: - CI: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..8ecea55 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,14 @@ +name: Run Tests + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: denoland/setup-deno@v1 + with: + deno-version: v1.22.0 + - run: | + make test diff --git a/.gitignore b/.gitignore index 59ee055..4c49bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1 @@ -.DS_Store -node_modules -*.log -*.zip .env -.next -.env.build -.serverless -.serverless_nextjs -**/public/precache.*.*.js -**/public/sw.js -**/public/workbox-*.js -**/public/worker-*.js -**/public/fallback-*.js -**/public/precache.*.*.js.map -**/public/sw.js.map -**/public/workbox-*.js.map -**/public/worker-*.js.map -**/public/fallback-*.js -*.tsbuildinfo diff --git a/.npmrc b/.npmrc deleted file mode 100644 index cffe8cd..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -save-exact=true diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index f0b10f1..0000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v16.13.1 diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 17c3147..0000000 --- a/.prettierignore +++ /dev/null @@ -1,15 +0,0 @@ -node_modules -.next -package-lock.json -.serverless -.serverless_nextjs -**/public/precache.*.*.js -**/public/sw.js -**/public/workbox-*.js -**/public/worker-*.js -**/public/fallback-*.js -**/public/precache.*.*.js.map -**/public/sw.js.map -**/public/workbox-*.js.map -**/public/worker-*.js.map -**/public/fallback-*.js diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index 6fe40d5..0000000 --- a/.prettierrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - trailingComma: 'all', - tabWidth: 2, - singleQuote: true, - arrowParens: 'always', -}; diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e1533c2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "deno.enable": true, + "deno.lint": true +} diff --git a/Makefile b/Makefile index 0fbce29..d19c121 100644 --- a/Makefile +++ b/Makefile @@ -1,39 +1,13 @@ -.PHONY: install -install: - -cp -n .env.sample .env - npm install --legacy-peer-deps - .PHONY: start start: - npm run dev + deno run --watch --allow-net --allow-read --allow-env=PORT,USERBASE_APP_ID main.ts + +.PHONY: format +format: + deno fmt .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: - serverless + deno fmt --check + deno lint + deno test --allow-net --allow-read --allow-env=PORT,USERBASE_APP_ID --check=all diff --git a/README.md b/README.md index 2d9d7ce..6234742 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,39 @@ [![](https://github.com/BrunoBernardino/budgetzen-web/workflows/Run%20Tests/badge.svg)](https://github.com/BrunoBernardino/budgetzen-web/actions?workflow=Run+Tests) -This is the web app for the [Budget Zen app](https://budgetzen.net), built with Next.js and deployed to AWS with Serverless. +This is the web app for the [Budget Zen app](https://budgetzen.net), built with [Deno](https://deno.land) and deployed to [Deno Deploy](https://deno.com/deploy). This is v2, which is [end-to-end encrypted via userbase](https://userbase.com), and works via web on any device (it's a PWA - Progressive Web App). It's not compatible with Budget Zen v1 (not end-to-end encrypted), which you can still get locally from [this commit](https://github.com/BrunoBernardino/budgetzen-web/tree/397d625469b7dfd8d1968c847b32e607ee7c8ee9). You can still export and import the data as the JSON format is the same (unencrypted). +## Requirements + +This was tested with `deno@1.22.0`, though it's possible older versions might work. + +There are no other dependencies. **Deno**! + ## 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 `serverless` to be installed globally) +```sh +$ make start +$ make format +$ make test ``` -## TODOs +## Structure + +This is vanilla JS, web standards, no frameworks. If you'd like to see/use [the Next.js version deployed to AWS via Serverless, check this commit](https://github.com/BrunoBernardino/budgetzen-web/tree/b1097c710ba89abf9aed044a7d7444e91d04a6a7). + +- Backend routes are defined at `routes.ts`. +- Static files are defined at `public/`. +- Pages are defined at `pages/`. + +## Deployment + +- Deno Deploy: Just push to the `main` branch. Any other branch will create a preview deployment. + +## TODOs: -- [ ] Improve UI/UX in general -- [ ] Improve dark/light mode +- [ ] Enable true offline mode (securely cache data, allow read-only) + - https://github.com/smallbets/userbase/issues/255 has interesting ideas, while it's not natively supported diff --git a/components/Budget/index.tsx b/components/Budget/index.tsx deleted file mode 100644 index 65947ee..0000000 --- a/components/Budget/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 73b9acf..0000000 --- a/components/BudgetModal.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import styled from 'styled-components'; -import Rodal from 'rodal'; -import Swal from 'sweetalert2'; - -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; -} - -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 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 } = 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.substring(0, 7) : '', - }; - - const success = await saveBudget(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(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} - /> - - - - {Boolean(id) && ( - - )} - - - ); -}; - -export default BudgetModal; diff --git a/components/Button/__snapshots__/test.tsx.snap b/components/Button/__snapshots__/test.tsx.snap deleted file mode 100644 index 14165c8..0000000 --- a/components/Button/__snapshots__/test.tsx.snap +++ /dev/null @@ -1,11 +0,0 @@ -// 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 deleted file mode 100644 index da6747e..0000000 --- a/components/Button/index.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React, { forwardRef } from 'react'; -import styled from 'styled-components'; - -import styles from './styles.module.scss'; - -interface ButtonProps { - element?: 'button' | 'a'; - isDisabled?: boolean; - type?: 'primary' | 'secondary' | 'delete'; - href?: string; - onClick?: any; - className?: string; - width?: '' | 'large' | 'tiny'; - style?: any; -} - -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: `${styles[className] || ''} ${styles.Button} ${ - styles[`Button--${type}`] - } ${width ? styles[`Button--${width}`] : ''}`, - })``; - return ( - - ); - } - - const StyledAnchor = styled.a.attrs({ - className: `${styles[className] || ''} ${styles.Button} ${ - styles[`Button--${type}`] - } ${width ? styles[`Button--${width}`] : ''}`, - })``; - - return ; - }, -); - -Button.defaultProps = { - element: 'button', - isDisabled: false, - type: 'primary', - width: '', -}; - -export default Button; diff --git a/components/Button/styles.module.scss b/components/Button/styles.module.scss deleted file mode 100644 index ee6da09..0000000 --- a/components/Button/styles.module.scss +++ /dev/null @@ -1,87 +0,0 @@ -@import 'styles/__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.3rem; - line-height: 1.5rem; - margin: 0 auto; - text-align: center; - padding: 1rem 1.3rem; - 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: none; - - &: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-light-gray; - - @media (prefers-color-scheme: dark) { - background-color: $color-text-gray; - } - @at-root .theme-dark #{&} { - background-color: $color-text-gray; - } - - &: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 deleted file mode 100644 index 18a1776..0000000 --- a/components/Button/test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 7a8230f..0000000 --- a/components/Expense/index.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index c825cf0..0000000 --- a/components/ExpenseModal.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import styled from 'styled-components'; -import Rodal from 'rodal'; -import Swal from 'sweetalert2'; - -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; -} - -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 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 } = 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(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(id); - - setIsSubmitting(false); - - if (success) { - showNotification('Expense deleted successfully.'); - onClose(); - } - - await reloadData(); - }; - - const onKeyDown = useCallback( - (event: React.KeyboardEvent) => { - // @ts-ignore: Convert comma to dot - if (event.key === ',' && event.target.type === 'number') { - event.preventDefault(); - event.stopPropagation(); - setCost(`${cost}.`); - } - - if (event.key === 'Enter') { - event.preventDefault(); - event.stopPropagation(); - addExpense(); - } - }, - [cost, description, budget, date], - ); - - 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} - /> - - - - {Boolean(id) && ( - - )} - - - ); -}; - -export default ExpenseModal; diff --git a/components/FilterBudgetModal.tsx b/components/FilterBudgetModal.tsx deleted file mode 100644 index 25337d2..0000000 --- a/components/FilterBudgetModal.tsx +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 0f34da6..0000000 --- a/components/IconButton.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index e3fbc27..0000000 --- a/components/ImportExportModal.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import React, { useState } from 'react'; -import styled from 'styled-components'; -import Rodal from 'rodal'; -import Swal from 'sweetalert2'; - -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 { - isOpen: boolean; - onClose: () => void; - setIsLoading: (isLoading: boolean) => 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 ImportExportModal = (props: ImportExportModalProps) => { - const [isSubmitting, setIsSubmitting] = useState(false); - - const { isOpen, onClose, setIsLoading } = 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); - setIsLoading(true); - - const success = await importData( - mergeOrReplaceDialogResult.isDenied, - budgets, - expenses, - ); - - setIsSubmitting(false); - setIsLoading(false); - - if (success) { - onClose(); - } - } - }; - - reader.readAsText(importFileDialogResult.value); - }; - - const onRequestExport = async () => { - if (isSubmitting) { - // Ignore sequential taps - return; - } - - setIsSubmitting(true); - - const fileName = `data-export-${new Date() - .toISOString() - .substring(0, 19) - .replace(/:/g, '-')}.json`; - - const exportData = await exportAllData(); - - 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 (v1 or v2) before. - - - - - - - - - - ); -}; - -export default ImportExportModal; diff --git a/components/Layout/Footer.module.scss b/components/Layout/Footer.module.scss deleted file mode 100644 index 8885c89..0000000 --- a/components/Layout/Footer.module.scss +++ /dev/null @@ -1,89 +0,0 @@ -@import 'styles/__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; - } - @at-root .theme-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; - text-align: center; - - @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; - } - @at-root .theme-dark #{&} { - color: $color-menu-background-hover; - } - - &:hover, - &:focus { - @media (prefers-color-scheme: dark) { - color: #f3f3f3; - } - @at-root .theme-dark #{&} { - color: #f3f3f3; - } - } - } - } -} diff --git a/components/Layout/Footer.test.tsx b/components/Layout/Footer.test.tsx deleted file mode 100644 index b485924..0000000 --- a/components/Layout/Footer.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -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(