├── .env ├── .gitignore ├── .prettierrc ├── .travis.yml ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── App.tsx ├── components │ ├── Auth │ │ ├── Login.test.tsx │ │ ├── Login.tsx │ │ ├── LogoutButton.test.tsx │ │ └── LogoutButton.tsx │ ├── Bar.test.tsx │ ├── Bar.tsx │ ├── DocTabsRadio │ │ └── DocTabsRadioSection.tsx │ ├── DrivePicker.tsx │ ├── Editor │ │ ├── DynamicVariables.tsx │ │ ├── Editor.tsx │ │ └── EmailEditor.tsx │ ├── Hello.test.tsx │ ├── Hello.tsx │ ├── Sender │ │ ├── Recipients.tsx │ │ └── Sender.tsx │ ├── Stepper │ │ ├── Navigation.tsx │ │ └── Steps.tsx │ └── utils.ts ├── config │ ├── gmail.client.ts │ └── google.client.ts ├── const │ └── steps.tsx ├── context │ ├── email.ts │ ├── mail-template.ts │ ├── sheet.ts │ ├── spreadsheet.ts │ ├── step.ts │ └── user.ts ├── hooks │ ├── localstorage.hook.ts │ └── useStyles.ts ├── index.tsx ├── models │ ├── EmailMeta.ts │ ├── Nullable.ts │ ├── Recipient.ts │ ├── Sheet.ts │ ├── Spreadsheet.ts │ ├── Step.ts │ ├── User.ts │ ├── UserToken.ts │ └── index.ts ├── providers │ └── spreadsheet.provider.ts ├── react-app-env.d.ts ├── seeds │ └── mail.ts ├── serviceWorker.ts ├── services │ ├── GenerateEML.ts │ ├── MailSender.ts │ └── spreadsheet.service.ts ├── theme │ └── theme.ts ├── transformers │ ├── spreadsheet.transformer.ts │ └── user.transformer.ts └── utils │ ├── spreadsheet.ts │ └── testing.tsx ├── tsconfig.json ├── tslint.json └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_NAME = 'Mailinger' 2 | REACT_APP_GOOGLE_ID = 3 | REACT_APP_GOOGLE_SECRECT = -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | /.vscode 24 | /.idea -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": true, 4 | "semi": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10.15.3" 4 | cache: 5 | directories: 6 | - node_modules 7 | script: 8 | - yarn global add codecov 9 | - yarn coverage 10 | after_success: 11 | - codecov 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/SoftwareBrothers/mailinger.svg?branch=master)](https://travis-ci.org/SoftwareBrothers/mailinger) 2 | [![codecov](https://codecov.io/gh/SoftwareBrothers/mailinger/branch/master/graph/badge.svg)](https://codecov.io/gh/SoftwareBrothers/mailinger) 3 | # Mailinger 4 | This is a system which allow to send many emails to people using a template in WYSIWYG editor and data from the spreadsheet. It has integration with Google API, Gmail API and Spreadsheet API. 5 | 6 | ## Getting Started 7 | Copy env file and edit it appropriately 8 | ``` 9 | cp .env .env.local 10 | ``` 11 | Install dependencies 12 | ``` 13 | yarn 14 | ``` 15 | Run the application 16 | ``` 17 | yarn start 18 | ``` 19 | 20 | ## Tests 21 | 22 | If you want to run tests in development mode, simply run `yarn test`. It will run tests for files that has been changed since last commit. It will run tests in `--watch` mode. 23 | You can also do one-time run with code coverage of all tests, run `yarn coverage`. 24 | 25 | ### How to setup test runner in IntelliJ IDEA / PHPStorm / WebStorm? 26 | 27 | 1. Click `Add Configurations` 28 | 2. Click `+` on the top-left corner of the screen, then select `Jest` 29 | 3. Give it a good name, like `All Tests` 30 | 3. Click 'Ok' 31 | 32 | That's it! Now you have 3 icons available: `Run 'All Tests'`, `Debug 'All Tests'` and `Run 'All Tests' with Coverage`. Click on the last one and see that project tree will now have information about code coverage! 33 | 34 | ## License 35 | 36 | mailinger is Copyright © 2019 SoftwareBrothers.co. It is free software, and may be redistributed under the terms specified in the [LICENSE](LICENSE) file. 37 | 38 | ## About SoftwareBrothers.co 39 | 40 | 41 | 42 | We are a software company who provides web and mobile development and UX/UI services, friendly team that helps clients from all over the world to transform their businesses and create astonishing products. 43 | 44 | - We are available to [hire](https://softwarebrothers.co/contact). 45 | - If you want to work for us - checkout the [career page](https://softwarebrothers.co/career). 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mailinger", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "^4.0.2", 7 | "@material-ui/icons": "^4.0.1", 8 | "@material-ui/styles": "^4.0.2", 9 | "@types/jest": "24.0.13", 10 | "@types/node": "12.0.4", 11 | "@types/react": "16.8.19", 12 | "@types/react-dom": "16.8.4", 13 | "axios": "^0.19.0", 14 | "draft-js": "^0.10.5", 15 | "draft-js-export-html": "^1.3.3", 16 | "draft-js-import-html": "^1.3.3", 17 | "js-base64": "^2.5.1", 18 | "react": "^16.8.6", 19 | "react-dom": "^16.8.6", 20 | "react-draft-wysiwyg": "^1.13.2", 21 | "react-google-login": "^5.0.4", 22 | "react-google-picker": "^0.1.0", 23 | "react-scripts": "3.0.1", 24 | "typescript": "3.5.1" 25 | }, 26 | "scripts": { 27 | "start": "react-scripts start", 28 | "build": "react-scripts build", 29 | "test": "react-scripts test", 30 | "coverage": "react-scripts test --coverage", 31 | "eject": "react-scripts eject" 32 | }, 33 | "eslintConfig": { 34 | "extends": "react-app" 35 | }, 36 | "browserslist": [ 37 | ">0.2%", 38 | "not dead", 39 | "not ie <= 11", 40 | "not op_mini all" 41 | ], 42 | "devDependencies": { 43 | "@testing-library/react": "^8.0.1", 44 | "@types/draft-js": "^0.10.32", 45 | "@types/js-base64": "^2.3.1", 46 | "@types/react-draft-wysiwyg": "^1.12.2", 47 | "prettier": "^1.17.1", 48 | "tslint": "^5.17.0", 49 | "tslint-config-prettier": "^1.18.0", 50 | "tslint-react": "^4.0.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SoftwareBrothers/mailinger/31e21f1f190a760fbdb69872cf50d7ab7e3b68d9/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 16 | 17 | 26 | Mailinger 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { CssBaseline } from '@material-ui/core'; 2 | import ThemeProvider from '@material-ui/styles/ThemeProvider'; 3 | import Bar from 'components/Bar'; 4 | import Steps from 'components/Stepper/Steps'; 5 | import { UserCtx } from 'context/user'; 6 | import { useLocalStorage } from 'hooks/localstorage.hook'; 7 | import { User } from 'models'; 8 | import React, { memo } from 'react'; 9 | import theme from 'theme/theme'; 10 | import { createUserFromLocalStorage } from 'transformers/user.transformer'; 11 | 12 | const hasTokenExpired = (userObj: User): boolean => { 13 | return userObj && userObj.token && new Date() > userObj.token.expiresAt; 14 | }; 15 | 16 | function App() { 17 | const [user, setUser, removeUser] = useLocalStorage( 18 | 'user', 19 | null as any, 20 | createUserFromLocalStorage, 21 | ); 22 | 23 | if (hasTokenExpired(user)) { 24 | removeUser(); 25 | } 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | {user ? : null} 33 | 34 | 35 | ); 36 | } 37 | 38 | export default memo(App); 39 | -------------------------------------------------------------------------------- /src/components/Auth/Login.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup } from '@testing-library/react'; 2 | import React from 'react'; 3 | import { contextRender } from 'utils/testing'; 4 | import Login from './Login'; 5 | 6 | describe('Login', () => { 7 | afterEach(cleanup); 8 | 9 | it('renders without errors', () => { 10 | contextRender()(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/Auth/Login.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@material-ui/core/Button'; 2 | import { UserCtx } from 'context/user'; 3 | import { User } from 'models'; 4 | import React, { memo, useContext } from 'react'; 5 | import GoogleLogin from 'react-google-login'; 6 | import { createUserFromJson } from 'transformers/user.transformer'; 7 | 8 | const CLIENT_ID = process.env.REACT_APP_GOOGLE_ID || ''; 9 | 10 | const Login = () => { 11 | const { setUser } = useContext(UserCtx); 12 | 13 | function responseGoogle(response: any) { 14 | const loggedUser: User = createUserFromJson(response); 15 | setUser(loggedUser); 16 | } 17 | 18 | const renderButton = (renderProps: any) => ( 19 | 22 | ); 23 | 24 | return ( 25 | 33 | ); 34 | }; 35 | export default memo(Login); 36 | -------------------------------------------------------------------------------- /src/components/Auth/LogoutButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, fireEvent } from '@testing-library/react'; 2 | import React from 'react'; 3 | import { contextRender } from 'utils/testing'; 4 | import LogoutButton from './LogoutButton'; 5 | 6 | describe('LogoutButton', () => { 7 | afterEach(cleanup); 8 | 9 | it('renders without errors', () => { 10 | contextRender()(); 11 | }); 12 | 13 | it('calls "removeUser" when button is clicked', () => { 14 | const { 15 | render: { getByText }, 16 | userContext: { removeUser }, 17 | } = contextRender()(); 18 | 19 | fireEvent.click(getByText('Log out')); 20 | 21 | expect(removeUser).toHaveBeenCalledTimes(1); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/components/Auth/LogoutButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@material-ui/core'; 2 | import { UserCtx } from 'context/user'; 3 | import React, { memo, useContext } from 'react'; 4 | 5 | const LogoutButton = () => { 6 | const { removeUser } = useContext(UserCtx); 7 | 8 | return ( 9 | 12 | ); 13 | }; 14 | 15 | export default memo(LogoutButton); 16 | -------------------------------------------------------------------------------- /src/components/Bar.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup } from '@testing-library/react'; 2 | import { UserContext } from 'context/user'; 3 | import { User } from 'models'; 4 | import React from 'react'; 5 | import { contextRender } from 'utils/testing'; 6 | import Bar from './Bar'; 7 | 8 | const dummyUser: User = { 9 | email: 'user@user.com', 10 | firstName: 'Test', 11 | googleId: 123456, 12 | lastName: 'User', 13 | name: 'user', 14 | token: { 15 | accessToken: 'abc123', 16 | expiresAt: new Date(), 17 | idToken: 'token', 18 | }, 19 | }; 20 | 21 | describe('Bar', () => { 22 | afterEach(cleanup); 23 | 24 | it('renders without errors', () => { 25 | contextRender()(); 26 | }); 27 | 28 | it('renders "hello" when user is logged in', () => { 29 | const userContext: UserContext = { 30 | removeUser: () => null, 31 | setUser: () => null, 32 | user: { 33 | ...dummyUser, 34 | }, 35 | }; 36 | 37 | const { 38 | render: { getByTestId }, 39 | } = contextRender(userContext)(); 40 | 41 | expect(getByTestId('toolbar').textContent).toContain('Hello'); 42 | }); 43 | 44 | it('renders "login" when user is not logged in', () => { 45 | const userContext: UserContext = { 46 | removeUser: () => null, 47 | setUser: () => null, 48 | user: undefined, 49 | }; 50 | 51 | const { 52 | render: { getByTestId }, 53 | } = contextRender(userContext)(); 54 | 55 | expect(getByTestId('toolbar').textContent).toContain('Login'); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/components/Bar.tsx: -------------------------------------------------------------------------------- 1 | import AppBar from '@material-ui/core/AppBar'; 2 | import Toolbar from '@material-ui/core/Toolbar'; 3 | import Typography from '@material-ui/core/Typography'; 4 | import LogoutButton from 'components/Auth/LogoutButton'; 5 | import { UserCtx } from 'context/user'; 6 | import { useStyles } from 'hooks/useStyles'; 7 | import React, { FunctionComponent, memo, useContext } from 'react'; 8 | import Login from './Auth/Login'; 9 | import Hello from './Hello'; 10 | 11 | const styles = { 12 | grow: { 13 | flexGrow: 1, 14 | }, 15 | root: { 16 | flexGrow: 1, 17 | marginBottom: '1rem', 18 | }, 19 | }; 20 | 21 | const Bar: FunctionComponent = () => { 22 | const classes = useStyles(styles); 23 | const { user } = useContext(UserCtx); 24 | 25 | return ( 26 |
27 | 28 | 29 | 30 | {process.env.REACT_APP_NAME} 31 | 32 | {user ? : } 33 | {user && } 34 | 35 | 36 |
37 | ); 38 | }; 39 | 40 | export default memo(Bar); 41 | -------------------------------------------------------------------------------- /src/components/DocTabsRadio/DocTabsRadioSection.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FormControl, 3 | FormControlLabel, 4 | FormLabel, 5 | Radio, 6 | RadioGroup, 7 | } from '@material-ui/core'; 8 | import { SheetCtx } from 'context/sheet'; 9 | import { Sheet, Spreadsheet } from 'models'; 10 | import React, { memo, useContext } from 'react'; 11 | import { getSheetByTitle } from 'utils/spreadsheet'; 12 | 13 | interface OwnProps { 14 | readonly spreadsheet: Spreadsheet; 15 | } 16 | 17 | type Props = OwnProps; 18 | 19 | const DocTabsRadioSection = ({ spreadsheet }: Props) => { 20 | const { sheet, setSheet } = useContext(SheetCtx); 21 | const initSheet = sheet ? sheet : spreadsheet.sheets[0]; 22 | setSheet(initSheet); 23 | 24 | const assignNewSheet = (sheetToAssign: Sheet) => { 25 | const newSheet = { 26 | ...sheet, 27 | title: sheetToAssign.title, 28 | usersData: sheetToAssign.usersData, 29 | variables: sheetToAssign.variables, 30 | }; 31 | setSheet(newSheet); 32 | }; 33 | 34 | const onRadioChange = (event: React.ChangeEvent) => { 35 | const sheetTitle = (event.target as HTMLInputElement).value; 36 | const sheetSelected = getSheetByTitle(spreadsheet, sheetTitle); 37 | 38 | if (!sheetSelected) { 39 | return; 40 | } 41 | 42 | assignNewSheet(sheetSelected); 43 | }; 44 | 45 | return ( 46 | 47 | Tab to export 48 | 55 | {spreadsheet.sheets.map(tab => ( 56 | } 60 | label={tab.title} 61 | /> 62 | ))} 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default memo(DocTabsRadioSection); 69 | -------------------------------------------------------------------------------- /src/components/DrivePicker.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Grid, Theme } from '@material-ui/core'; 2 | import CircularProgress from '@material-ui/core/CircularProgress'; 3 | import StorageIcon from '@material-ui/icons/Storage'; 4 | import { SpreadsheetCtx } from 'context/spreadsheet'; 5 | import { StepCtx } from 'context/step'; 6 | import { useStyles } from 'hooks/useStyles'; 7 | import React, { memo, useContext, useState } from 'react'; 8 | // @ts-ignore 9 | import GooglePicker from 'react-google-picker'; 10 | import SpreadSheetService from 'services/spreadsheet.service'; 11 | import DocTabsRadioSection from './DocTabsRadio/DocTabsRadioSection'; 12 | 13 | const CLIENT_ID = process.env.REACT_APP_GOOGLE_ID || ''; 14 | const DEVELOPER_KEY = process.env.REACT_APP_DEVELOPER_KEY || ''; 15 | 16 | const pickerOnAuthFailed = (error: any) => { 17 | console.log('Picker auth failed error:', error); 18 | }; 19 | 20 | const styles = (theme: Theme) => ({ 21 | embed: { 22 | marginTop: 25, 23 | minHeight: 550, 24 | width: '90%', 25 | }, 26 | root: { 27 | padding: 'auto', 28 | textAlign: 'center' as any, 29 | }, 30 | storageIcon: { 31 | marginRight: theme.spacing(2), 32 | }, 33 | }); 34 | 35 | export enum DriveState { 36 | READY = 'ready', 37 | LOADING = 'picked', 38 | } 39 | 40 | const DrivePicker = () => { 41 | const { spreadsheet, setSpreadsheet } = useContext(SpreadsheetCtx); 42 | const [step, setStep] = useContext(StepCtx); 43 | const [driveState, setDriveState] = useState(DriveState.READY); 44 | const classes = useStyles(styles); 45 | const service = new SpreadSheetService(); 46 | 47 | const onChange = async (data: any) => { 48 | const result = await service.onFilePicked( 49 | setDriveState, 50 | data, 51 | setSpreadsheet, 52 | ); 53 | if (result) { 54 | setDriveState(DriveState.READY); 55 | const currentStep = { ...step, isBlocked: false }; 56 | setStep(currentStep); 57 | } 58 | }; 59 | 60 | const renderEmbed = () => { 61 | if (spreadsheet && spreadsheet.embedUrl) { 62 | return ; 63 | } 64 | return null; 65 | }; 66 | 67 | return ( 68 | 69 | 76 | {driveState === DriveState.LOADING ? ( 77 | 78 | ) : ( 79 | 83 | )} 84 | 85 | 86 | {renderEmbed()} 87 | 88 | {spreadsheet && spreadsheet.sheets && ( 89 |
90 | 91 |
92 | )} 93 | 94 |
95 | ); 96 | }; 97 | 98 | export default memo(DrivePicker); 99 | -------------------------------------------------------------------------------- /src/components/Editor/DynamicVariables.tsx: -------------------------------------------------------------------------------- 1 | import { Theme } from '@material-ui/core'; 2 | import Chip from '@material-ui/core/Chip'; 3 | import Paper from '@material-ui/core/Paper'; 4 | import Typography from '@material-ui/core/Typography'; 5 | import { SheetCtx } from 'context/sheet'; 6 | import { SpreadsheetCtx } from 'context/spreadsheet'; 7 | import { useStyles } from 'hooks/useStyles'; 8 | import React, { memo, useContext } from 'react'; 9 | 10 | const styles = (theme: Theme) => ({ 11 | chip: { 12 | marginBottom: `${theme.spacing(1)}px`, 13 | marginRight: `${theme.spacing(1)}px`, 14 | marginTop: `${theme.spacing(1)}px`, 15 | }, 16 | paper: { 17 | margin: `${theme.spacing(1)}px 0`, 18 | padding: `${theme.spacing(1)}px`, 19 | }, 20 | }); 21 | 22 | const DynamicVariables = () => { 23 | const { sheet } = useContext(SheetCtx); 24 | const classes = useStyles(styles); 25 | const variables: string[] = (sheet && sheet.variables) || []; 26 | 27 | if (!variables.length) { 28 | return null; 29 | } 30 | 31 | return ( 32 | 33 | 34 | {'Available variables: '} 35 | 36 | {variables.map(variable => ( 37 | 42 | ))} 43 | 44 | ); 45 | }; 46 | 47 | export default memo(DynamicVariables); 48 | -------------------------------------------------------------------------------- /src/components/Editor/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { Theme } from '@material-ui/core'; 2 | import { replaceVars } from 'components/utils'; 3 | import { MailTemplateCtx } from 'context/mail-template'; 4 | import { SheetCtx } from 'context/sheet'; 5 | import { EditorState } from 'draft-js'; 6 | import { stateToHTML } from 'draft-js-export-html'; 7 | import { stateFromHTML } from 'draft-js-import-html'; 8 | import { useStyles } from 'hooks/useStyles'; 9 | import React, { memo, useContext, useState } from 'react'; 10 | import { Editor as Wysiwyg } from 'react-draft-wysiwyg'; 11 | import 'react-draft-wysiwyg/dist/react-draft-wysiwyg.css'; 12 | import DynamicVariables from './DynamicVariables'; 13 | 14 | const styles = (theme: Theme) => ({ 15 | root: { 16 | padding: `${theme.spacing(2)}px`, 17 | }, 18 | }); 19 | 20 | const Editor = () => { 21 | const [mailTemplate, setMailTemplate] = useContext(MailTemplateCtx); 22 | const { sheet } = useContext(SheetCtx); 23 | const [editor, setEditor] = useState( 24 | EditorState.createWithContent(stateFromHTML(mailTemplate)), 25 | ); 26 | const [preview, setPreview] = useState( 27 | replaceVars(mailTemplate, sheet.usersData[0]), 28 | ); 29 | const classes = useStyles(styles); 30 | 31 | const onChange = (data: any) => { 32 | setEditor(data); 33 | setMailTemplate(stateToHTML(data.getCurrentContent())); 34 | setPreview( 35 | replaceVars(stateToHTML(data.getCurrentContent()), sheet.usersData[0]), 36 | ); 37 | }; 38 | 39 | const options = ['fontSize', 'fontFamily', 'list', 'textAlign']; 40 | 41 | return ( 42 |
43 | 44 | 51 |

Preview

52 |
53 |
54 | ); 55 | }; 56 | 57 | export default memo(Editor); 58 | -------------------------------------------------------------------------------- /src/components/Editor/EmailEditor.tsx: -------------------------------------------------------------------------------- 1 | import { EmailData } from 'context/email'; 2 | import { EditorState } from 'draft-js'; 3 | import { stateToHTML } from 'draft-js-export-html'; 4 | import { stateFromHTML } from 'draft-js-import-html'; 5 | import React, { memo, useState } from 'react'; 6 | import { Editor as Wysiwyg } from 'react-draft-wysiwyg'; 7 | 8 | interface OwnProps { 9 | readonly item: EmailData; 10 | readonly updater: (data: any) => void; 11 | } 12 | 13 | type Props = OwnProps; 14 | 15 | const options = ['fontSize', 'fontFamily', 'list', 'textAlign']; 16 | const EmailEditor = ({ item, updater }: Props) => { 17 | const [editor, setEditor] = useState( 18 | EditorState.createWithContent(stateFromHTML(item.content)), 19 | ); 20 | 21 | const onChange = (data: any) => { 22 | item.content = stateToHTML(data.getCurrentContent()); 23 | setEditor(data); 24 | updater(item); 25 | }; 26 | 27 | return ( 28 | 33 | ); 34 | }; 35 | 36 | export default memo(EmailEditor); 37 | -------------------------------------------------------------------------------- /src/components/Hello.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup } from '@testing-library/react'; 2 | import { UserContext } from 'context/user'; 3 | import { User } from 'models'; 4 | import React from 'react'; 5 | import { contextRender } from 'utils/testing'; 6 | import Hello from './Hello'; 7 | 8 | const dummyUser: User = { 9 | email: 'user@user.com', 10 | firstName: 'Test', 11 | googleId: 123456, 12 | lastName: 'User', 13 | name: 'user', 14 | token: { 15 | accessToken: 'abc123', 16 | expiresAt: new Date(), 17 | idToken: 'token', 18 | }, 19 | }; 20 | 21 | describe('Hello', () => { 22 | afterEach(cleanup); 23 | 24 | it('renders without errors', () => { 25 | contextRender()(); 26 | }); 27 | 28 | it('renders "hello" and username when user is logged in', () => { 29 | const userContext: UserContext = { 30 | removeUser: () => null, 31 | setUser: () => null, 32 | user: { 33 | ...dummyUser, 34 | }, 35 | }; 36 | 37 | const { 38 | render: { getByTestId }, 39 | } = contextRender(userContext)(); 40 | 41 | expect(getByTestId('hello-content').textContent).toContain('user'); 42 | }); 43 | 44 | it('renders nothing when user is not logged in', () => { 45 | const userContext: UserContext = { 46 | removeUser: () => null, 47 | setUser: () => null, 48 | user: undefined, 49 | }; 50 | 51 | const { render } = contextRender(userContext)(); 52 | 53 | expect(render.container.textContent).toBeFalsy(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/components/Hello.tsx: -------------------------------------------------------------------------------- 1 | import { Theme } from '@material-ui/core'; 2 | import Typography from '@material-ui/core/Typography'; 3 | import { UserCtx } from 'context/user'; 4 | import { useStyles } from 'hooks/useStyles'; 5 | import React, { memo, useContext } from 'react'; 6 | 7 | const styles = (theme: Theme) => ({ 8 | hello: { 9 | marginRight: theme.spacing(1), 10 | }, 11 | }); 12 | 13 | const Hello = () => { 14 | const { user } = useContext(UserCtx); 15 | const classes = useStyles(styles); 16 | 17 | if (!user) { 18 | return null; 19 | } 20 | 21 | return ( 22 | 28 | Hello, {user.name} 29 | 30 | ); 31 | }; 32 | 33 | export default memo(Hello); 34 | -------------------------------------------------------------------------------- /src/components/Sender/Recipients.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Checkbox, 3 | Grid, 4 | List, 5 | ListItem, 6 | ListItemText, 7 | } from '@material-ui/core'; 8 | import Collapse from '@material-ui/core/Collapse'; 9 | import { green, lime, red } from '@material-ui/core/colors'; 10 | import Typography from '@material-ui/core/Typography'; 11 | import ExpandLess from '@material-ui/icons/ExpandLess'; 12 | import ExpandMore from '@material-ui/icons/ExpandMore'; 13 | import EmailEditor from 'components/Editor/EmailEditor'; 14 | import { EmailCtx, EmailData, EmailStatus } from 'context/email'; 15 | import { useStyles } from 'hooks/useStyles'; 16 | import React, { memo, useContext, useState } from 'react'; 17 | 18 | const Recipients = () => { 19 | const classes = useStyles(styles); 20 | const { data, setEmails } = useContext(EmailCtx); 21 | const [expanded, setExpanded] = useState(-1); 22 | 23 | const onChange = (index: number) => ( 24 | event: React.ChangeEvent, 25 | checked: boolean, 26 | ) => { 27 | const item: EmailData = { 28 | ...data[index], 29 | active: checked, 30 | }; 31 | 32 | setEmails([...data.slice(0, index), item, ...data.slice(index + 1)]); 33 | }; 34 | 35 | const onClick = (index: number) => () => { 36 | if (expanded === index) { 37 | setExpanded(-1); 38 | } else { 39 | setExpanded(index); 40 | } 41 | }; 42 | 43 | const update = (index: number) => (item: EmailData) => { 44 | setEmails([...data.slice(0, index), item, ...data.slice(index + 1)]); 45 | }; 46 | 47 | const formatStatus = (status: EmailStatus) => { 48 | switch (status) { 49 | case 1: 50 | return Pending...; 51 | case 2: 52 | return Sent.; 53 | case 3: 54 | return Error; 55 | default: 56 | return null; 57 | } 58 | }; 59 | 60 | if (data) { 61 | return ( 62 | 63 | 64 | 65 | {data.map((userData: EmailData, index: number) => ( 66 | <> 67 |
68 | 75 | 80 | 81 | 82 | {expanded === index ? : } 83 | 84 |
85 | 86 | 87 | 88 | 89 | 90 | 91 | ))} 92 |
93 |
94 |
95 | ); 96 | } 97 | return null; 98 | }; 99 | 100 | const styles = { 101 | center: { 102 | textAlign: 'center' as 'center', 103 | }, 104 | recipient: { 105 | width: '200px', 106 | }, 107 | root: { 108 | display: 'flex', 109 | }, 110 | statusError: { 111 | color: red['600'] 112 | }, 113 | statusPending: { 114 | color: lime['700'] 115 | }, 116 | statusSent: { 117 | color: green['600'] 118 | }, 119 | }; 120 | 121 | export default memo(Recipients); 122 | -------------------------------------------------------------------------------- /src/components/Sender/Sender.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Grid, TextField } from '@material-ui/core'; 2 | import Recipients from 'components/Sender/Recipients'; 3 | import { replaceVars } from 'components/utils'; 4 | import { EmailCtx, EmailData } from 'context/email'; 5 | import { MailTemplateCtx } from 'context/mail-template'; 6 | import { SheetCtx } from 'context/sheet'; 7 | import { UserCtx } from 'context/user'; 8 | import { useStyles } from 'hooks/useStyles'; 9 | import React, { memo, useContext, useEffect, useMemo, useState } from 'react'; 10 | import send from 'services/MailSender'; 11 | 12 | const styles = { 13 | center: { 14 | textAlign: 'center' as any, 15 | }, 16 | searchInput: { 17 | margin: 'auto', 18 | }, 19 | }; 20 | 21 | const prepareEmails = ( 22 | recipients: any, 23 | mailTemplate: string, 24 | subject: string, 25 | ) => { 26 | const prepared: EmailData = recipients.map((userData: any) => { 27 | if (userData.send) { 28 | return { 29 | active: true, 30 | content: replaceVars(mailTemplate, userData), 31 | firstName: userData.firstName, 32 | lastName: userData.lastName, 33 | recipient: userData.email, 34 | status: 0, 35 | title: subject, 36 | }; 37 | } 38 | return; 39 | }); 40 | 41 | return prepared; 42 | }; 43 | 44 | const Sender = () => { 45 | const [mailTemplate] = useContext(MailTemplateCtx); 46 | const { sheet } = useContext(SheetCtx); 47 | const { data, setEmails } = useContext(EmailCtx); 48 | const { user } = useContext(UserCtx); 49 | const [subject, setSubject] = useState('Proszę o wystawienie Faktury'); 50 | const classes = useStyles(styles); 51 | const recipients = sheet.usersData.filter( 52 | (spreadsheetUserData: any) => spreadsheetUserData.send, 53 | ); 54 | 55 | const update = (item: EmailData) => { 56 | const index = data.indexOf(item); 57 | 58 | if (index < 0) { 59 | return; 60 | } 61 | 62 | setEmails([...data.slice(0, index), item, ...data.slice(index + 1)]); 63 | }; 64 | 65 | const sendEmails = () => { 66 | if (user !== undefined) { 67 | send(data.filter((item: EmailData) => item.active), user, update); 68 | } 69 | }; 70 | 71 | const changeTitle = (event: React.ChangeEvent) => { 72 | setSubject(event.target.value); 73 | }; 74 | 75 | const prep = useMemo(() => prepareEmails(recipients, mailTemplate, subject), [ 76 | sheet, 77 | ]); 78 | 79 | useEffect(() => { 80 | setEmails(prep); 81 | }, []); 82 | 83 | return ( 84 | 85 | 95 | 96 | 99 | 100 | ); 101 | }; 102 | 103 | export default memo(Sender); 104 | -------------------------------------------------------------------------------- /src/components/Stepper/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import BottomNavigation from '@material-ui/core/BottomNavigation'; 2 | import BottomNavigationAction from '@material-ui/core/BottomNavigationAction'; 3 | import NavigateBeforeIcon from '@material-ui/icons/NavigateBefore'; 4 | import NavigateNextIcon from '@material-ui/icons/NavigateNext'; 5 | import { getStep } from 'const/steps'; 6 | import { StepCtx } from 'context/step'; 7 | import { useStyles } from 'hooks/useStyles'; 8 | import React, { memo, useContext } from 'react'; 9 | 10 | const styles = { 11 | buttons: { 12 | margin: '0 auto', 13 | }, 14 | }; 15 | 16 | const Navigation = () => { 17 | const [activeStep, setActiveStep] = useContext(StepCtx); 18 | const classes = useStyles(styles); 19 | 20 | function handleBack() { 21 | const step = getStep(activeStep.number - 1); 22 | setActiveStep(step); 23 | } 24 | 25 | function handleNext() { 26 | const step = getStep(activeStep.number + 1); 27 | setActiveStep(step); 28 | } 29 | 30 | return ( 31 | 32 | } 37 | onClick={handleBack} 38 | value="prev" 39 | /> 40 | } 45 | onClick={handleNext} 46 | value="next" 47 | /> 48 | 49 | ); 50 | }; 51 | 52 | export default memo(Navigation); 53 | -------------------------------------------------------------------------------- /src/components/Stepper/Steps.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from '@material-ui/core'; 2 | import MUIStep from '@material-ui/core/Step'; 3 | import StepButton from '@material-ui/core/StepButton'; 4 | import Stepper from '@material-ui/core/Stepper'; 5 | import { getStep } from 'const/steps'; 6 | import { EmailCtx, EmailData } from 'context/email'; 7 | import { MailTemplateCtx } from 'context/mail-template'; 8 | import { SheetCtx } from 'context/sheet'; 9 | import { SpreadsheetCtx } from 'context/spreadsheet'; 10 | import { StepCtx } from 'context/step'; 11 | import { useStyles } from 'hooks/useStyles'; 12 | import { Step } from 'models'; 13 | import React, { memo, useState } from 'react'; 14 | import { mailContent } from 'seeds/mail'; 15 | import Navigation from './Navigation'; 16 | 17 | const styles = { 18 | stepper: { 19 | background: 'none', 20 | }, 21 | }; 22 | 23 | const Steps = () => { 24 | const [activeStep, setActiveStep] = useState(getStep(0)); 25 | const [spreadsheet, setSpreadsheet] = useState(null); 26 | const [sheet, setSheet] = useState(null); 27 | const [emails, setEmails] = useState([]); 28 | const [mailTemplate, setMailTemplate] = useState(mailContent); 29 | const classes = useStyles(styles); 30 | 31 | const steps = [ 32 | { key: 'choose', label: 'Choose' }, 33 | { key: 'prepare_template', label: 'Prepare template' }, 34 | { key: 'send', label: 'Send' }, 35 | ]; 36 | 37 | function getComponent() { 38 | return (activeStep && activeStep.component) || null; 39 | } 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 54 | {steps.map(step => ( 55 | 56 | {step.label} 57 | 58 | ))} 59 | 60 | {getComponent()} 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | ); 70 | }; 71 | 72 | export default memo(Steps); 73 | -------------------------------------------------------------------------------- /src/components/utils.ts: -------------------------------------------------------------------------------- 1 | export const replaceVars = (input: string, userData: any) => { 2 | return input.replace(/\[(.*?)\]/g, (match, p1) => { 3 | if (userData) { 4 | return userData[p1]; 5 | } 6 | return match; 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /src/config/gmail.client.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios'; 2 | import googleClient from './google.client'; 3 | 4 | const headers = { 5 | 'Access-Control-Allow-Origin': '*', 6 | 'Content-Type': 'text/html; charset="UTF-8"', 7 | }; 8 | 9 | const gmailClient: AxiosInstance = Object.assign( 10 | { defaults: { headers } }, 11 | googleClient, 12 | ); 13 | 14 | export default gmailClient; 15 | -------------------------------------------------------------------------------- /src/config/google.client.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Nullable, User } from 'models'; 3 | 4 | const googleClient = axios.create({ 5 | baseURL: 'https://www.googleapis.com', 6 | }); 7 | 8 | const localUser: Nullable = localStorage.getItem('user'); 9 | 10 | if (localUser) { 11 | const user: User = JSON.parse(localUser); 12 | if (user.token) { 13 | googleClient.defaults.headers.common.Authorization = `Bearer ${ 14 | user.token.accessToken 15 | }`; 16 | } 17 | } 18 | 19 | export default googleClient; 20 | -------------------------------------------------------------------------------- /src/const/steps.tsx: -------------------------------------------------------------------------------- 1 | import DrivePicker from 'components/DrivePicker'; 2 | import Editor from 'components/Editor/Editor'; 3 | import Sender from 'components/Sender/Sender'; 4 | import { Step } from 'models'; 5 | import React from 'react'; 6 | 7 | export const DrivePickerStep: Step = { 8 | component: , 9 | isBlocked: true, 10 | number: 0, 11 | }; 12 | 13 | export const EditorStep: Step = { 14 | component: , 15 | isBlocked: false, 16 | number: 1, 17 | }; 18 | 19 | export const SenderStep: Step = { 20 | component: , 21 | isBlocked: true, 22 | number: 2, 23 | }; 24 | 25 | export function getStep(step: number): Step | null { 26 | switch (step) { 27 | case 0: 28 | return DrivePickerStep; 29 | case 1: 30 | return EditorStep; 31 | case 2: 32 | return SenderStep; 33 | default: 34 | return null; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/context/email.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export enum EmailStatus { 4 | NEW = 0, 5 | PENDING = 1, 6 | SENT = 2, 7 | ERROR = 3, 8 | } 9 | 10 | export interface EmailData { 11 | active: boolean; 12 | content: string; 13 | firstName: string; 14 | lastName: string; 15 | recipient: string; 16 | status: EmailStatus; 17 | title: string; 18 | } 19 | 20 | export interface EmailContext { 21 | readonly data: EmailData[]; 22 | readonly setEmails: (data: any) => void; 23 | } 24 | 25 | export const EmailCtx = createContext({ 26 | data: [], 27 | setEmails: () => null, 28 | }); 29 | -------------------------------------------------------------------------------- /src/context/mail-template.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { mailContent } from 'seeds/mail'; 3 | 4 | export const MailTemplateCtx = createContext(mailContent); 5 | -------------------------------------------------------------------------------- /src/context/sheet.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export const SheetCtx = createContext(null as any); 4 | -------------------------------------------------------------------------------- /src/context/spreadsheet.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export const SpreadsheetCtx = createContext(null as any); 4 | -------------------------------------------------------------------------------- /src/context/step.ts: -------------------------------------------------------------------------------- 1 | import { getStep } from 'const/steps'; 2 | import { createContext } from 'react'; 3 | 4 | export const StepCtx = createContext(getStep(1)) as any; 5 | -------------------------------------------------------------------------------- /src/context/user.ts: -------------------------------------------------------------------------------- 1 | import { User } from 'models'; 2 | import { createContext } from 'react'; 3 | 4 | export interface UserContext { 5 | readonly setUser: (user: User) => void; 6 | readonly removeUser: () => void; 7 | readonly user?: User; 8 | } 9 | 10 | export const UserCtx = createContext({ 11 | removeUser: () => null, 12 | setUser: () => null, 13 | }); 14 | -------------------------------------------------------------------------------- /src/hooks/localstorage.hook.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | export function useLocalStorage( 4 | key: string, 5 | initialValue: T, 6 | transformMethod?: (value: any) => T, 7 | ): [T, (value: any) => void, () => void] { 8 | const [storedValue, setStoredValue] = useState(() => { 9 | const storeItem = localStorage.getItem(key); 10 | const parsedItem = storeItem 11 | ? safeJsonParse(storeItem) 12 | : initialValue; 13 | 14 | if (typeof transformMethod === 'function') { 15 | return parsedItem ? transformMethod(parsedItem) : initialValue; 16 | } 17 | return parsedItem; 18 | }); 19 | 20 | const setValue = (value: any) => { 21 | try { 22 | const valueToStore = 23 | value instanceof Function ? value(storedValue) : value; 24 | setStoredValue(value); 25 | localStorage.setItem(key, JSON.stringify(valueToStore)); 26 | } catch (error) { 27 | // tslint:disable-next-line 28 | console.warn('Error', error); 29 | } 30 | }; 31 | 32 | const removeValue = () => { 33 | localStorage.removeItem(key); 34 | setStoredValue(null as any); 35 | }; 36 | 37 | return [storedValue, setValue, removeValue]; 38 | } 39 | 40 | function safeJsonParse(value: string): T { 41 | try { 42 | return JSON.parse(value); 43 | } catch (error) { 44 | return null as any; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/hooks/useStyles.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@material-ui/styles'; 2 | import { Styles } from '@material-ui/styles/withStyles'; 3 | import { useCallback } from 'react'; 4 | 5 | export const useStyles = (styles: Styles) => 6 | useCallback(makeStyles(styles), [])({}); 7 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import * as serviceWorker from './serviceWorker'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | 8 | // If you want your app to work offline and load faster, you can change 9 | // unregister() to register() below. Note this comes with some pitfalls. 10 | // Learn more about service workers: https://bit.ly/CRA-PWA 11 | serviceWorker.register(); 12 | -------------------------------------------------------------------------------- /src/models/EmailMeta.ts: -------------------------------------------------------------------------------- 1 | export interface EmailMeta { 2 | subject: string; 3 | content: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/models/Nullable.ts: -------------------------------------------------------------------------------- 1 | export type Nullable = T | null; 2 | -------------------------------------------------------------------------------- /src/models/Recipient.ts: -------------------------------------------------------------------------------- 1 | import { EmailMeta } from './EmailMeta'; 2 | import { Nullable } from './Nullable'; 3 | 4 | export interface Recipient { 5 | email: string; 6 | firstName: Nullable; 7 | lastName: Nullable; 8 | data: EmailMeta; 9 | } 10 | -------------------------------------------------------------------------------- /src/models/Sheet.ts: -------------------------------------------------------------------------------- 1 | export interface Sheet { 2 | title: string; 3 | variables: string[]; 4 | usersData: [{ [key: string]: any }]; 5 | } 6 | -------------------------------------------------------------------------------- /src/models/Spreadsheet.ts: -------------------------------------------------------------------------------- 1 | import { Sheet } from './Sheet'; 2 | 3 | export interface Spreadsheet { 4 | sheets: Sheet[]; 5 | embedUrl?: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/models/Step.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export interface Step { 4 | isBlocked: boolean; 5 | number: number; 6 | component: ReactNode; 7 | } 8 | -------------------------------------------------------------------------------- /src/models/User.ts: -------------------------------------------------------------------------------- 1 | import { UserToken } from './UserToken'; 2 | 3 | export interface User { 4 | email: string; 5 | firstName: string; 6 | lastName: string; 7 | googleId: number; 8 | name: string; 9 | token: UserToken; 10 | } 11 | -------------------------------------------------------------------------------- /src/models/UserToken.ts: -------------------------------------------------------------------------------- 1 | export interface UserToken { 2 | accessToken: string; 3 | expiresAt: Date; 4 | idToken: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './EmailMeta'; 2 | export * from './Nullable'; 3 | export * from './Recipient'; 4 | export * from './Sheet'; 5 | export * from './Spreadsheet'; 6 | export * from './Step'; 7 | export * from './User'; 8 | export * from './UserToken'; 9 | -------------------------------------------------------------------------------- /src/providers/spreadsheet.provider.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios'; 2 | import client from 'config/google.client'; 3 | 4 | export class SpreadSheetProvider { 5 | protected client: AxiosInstance; 6 | protected uri: string; 7 | 8 | public constructor() { 9 | this.uri = `https://sheets.googleapis.com/v4/spreadsheets`; 10 | this.client = client; 11 | } 12 | 13 | public provide(id: string) { 14 | return this.client.get(`${this.uri}/${id}?includeGridData=true`); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/seeds/mail.ts: -------------------------------------------------------------------------------- 1 | export const mailContent = ` 2 | Cześć [imie_nazwisko], 3 |
4 |
5 | Kwota [title], po uwzględnieniu potrącenia za: 6 |
7 |
8 | - angielski [ang] 9 |
10 | - enelmed [enel_med] 11 |
12 | - multisport [multisport] 13 |
14 | - zwolnienie chorobowe [kwota_l4] 15 |
16 | wynosi [kwota_netto] netto / [brutto_po_potraceniach] brutto. 17 |
18 |
19 | Dodatkowo proszę o wysłanie UZUPEŁNIONEGO raportu z jiry z podziałem na projekty w formacie pdf za poprzedni miesiąc. Najlepiej jakby faktura i załącznik były w jednym mailu 😉 20 |
21 | Czekam do jutra do godziny 12! 22 | `; 23 | -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, 20 | ), 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | (process as { env: { [key: string]: string } }).env.PUBLIC_URL, 33 | window.location.href, 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA', 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.', 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl) 112 | .then(response => { 113 | // Ensure service worker exists, and that we really are getting a JS file. 114 | const contentType = response.headers.get('content-type'); 115 | if ( 116 | response.status === 404 || 117 | (contentType != null && contentType.indexOf('javascript') === -1) 118 | ) { 119 | // No service worker found. Probably a different app. Reload the page. 120 | navigator.serviceWorker.ready.then(registration => { 121 | registration.unregister().then(() => { 122 | window.location.reload(); 123 | }); 124 | }); 125 | } else { 126 | // Service worker found. Proceed as normal. 127 | registerValidSW(swUrl, config); 128 | } 129 | }) 130 | .catch(() => { 131 | console.log( 132 | 'No internet connection found. App is running in offline mode.', 133 | ); 134 | }); 135 | } 136 | 137 | export function unregister() { 138 | if ('serviceWorker' in navigator) { 139 | navigator.serviceWorker.ready.then(registration => { 140 | registration.unregister(); 141 | }); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/services/GenerateEML.ts: -------------------------------------------------------------------------------- 1 | import { EmailData } from 'context/email'; 2 | import { Base64 } from 'js-base64'; 3 | import { User } from 'models'; 4 | 5 | export default (recipient: EmailData, user: User) => { 6 | const messageId = new Date().getUTCMilliseconds(); 7 | const userFullName = user.firstName + ' ' + user.lastName; 8 | const { firstName = '', lastName = '' } = recipient; 9 | const recipientFullName = `${firstName} ${lastName}`; 10 | 11 | const encode = (text: any) => { 12 | return '=?utf-8?B?' + Base64.encodeURI(text) + '?='; 13 | }; 14 | 15 | return `From: ${encode(userFullName)} <${user.email}> 16 | To: ${encode(recipientFullName)} <${recipient.recipient}> 17 | Reply-To: <${user.email}> 18 | Message-ID: ${messageId} 19 | Date: ${messageId} 20 | Subject: ${encode(recipient.title)} 21 | Content-Type: text/html; charset="UTF-8" 22 | 23 | ${recipient.content}`; 24 | }; 25 | -------------------------------------------------------------------------------- /src/services/MailSender.ts: -------------------------------------------------------------------------------- 1 | import client from 'config/gmail.client'; 2 | import { EmailData, EmailStatus } from 'context/email'; 3 | import { Base64 } from 'js-base64'; 4 | import { User } from 'models'; 5 | import eml from './GenerateEML'; 6 | 7 | const send = (recipients: EmailData[], user: User, updater: any) => { 8 | for (const recipient of recipients) { 9 | if (recipient) { 10 | const stream = eml(recipient, user); 11 | const data = Base64.encodeURI(stream); 12 | 13 | setStatus(recipient, EmailStatus.PENDING, updater); 14 | 15 | client 16 | .post('/gmail/v1/users/me/messages/send', { raw: data }) 17 | .then(res => { 18 | setStatus(recipient, EmailStatus.SENT, updater); 19 | }) 20 | .catch(error => { 21 | setStatus(recipient, EmailStatus.ERROR, updater); 22 | }); 23 | } 24 | } 25 | }; 26 | 27 | const setStatus = (recipient: EmailData, status: EmailStatus, updater: any) => { 28 | recipient.status = status; 29 | updater(recipient); 30 | }; 31 | 32 | export default send; 33 | -------------------------------------------------------------------------------- /src/services/spreadsheet.service.ts: -------------------------------------------------------------------------------- 1 | import { DriveState } from 'components/DrivePicker'; 2 | import { Spreadsheet } from 'models'; 3 | import { SpreadSheetProvider } from 'providers/spreadsheet.provider'; 4 | import { SpreadSheetTransformer } from 'transformers/spreadsheet.transformer'; 5 | 6 | export default class SpreadSheetService { 7 | public onFilePicked( 8 | setDriveState: (state: any) => void, 9 | data: any, 10 | setSpreadsheet: (data: Spreadsheet) => void, 11 | ) { 12 | return new Promise((resolve, reject) => { 13 | setDriveState(data.action); 14 | if (data.docs && data.docs[0]) { 15 | new SpreadSheetProvider() 16 | .provide(data.docs[0].id) 17 | .then(response => { 18 | const transformed = new SpreadSheetTransformer().transform( 19 | response.data, 20 | ); 21 | transformed.embedUrl = data.docs[0].embedUrl; 22 | setSpreadsheet(transformed); 23 | resolve(transformed); 24 | }) 25 | .catch(error => { 26 | console.log('Error while parsing document', error); 27 | setDriveState(DriveState.READY); 28 | reject(null); 29 | }); 30 | } 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/theme/theme.ts: -------------------------------------------------------------------------------- 1 | import createMuiTheme from '@material-ui/core/styles/createMuiTheme'; 2 | import { CSSProperties } from '@material-ui/core/styles/withStyles'; 3 | 4 | const primaryColor = '#e6282b'; 5 | 6 | const baseFontFamily = ['Helvetica', 'Arial', 'sans-serif']; 7 | const headingFontFamily = ['Roboto Condensed', ...baseFontFamily]; 8 | const typographyFontFamily = ['Open Sans', ...baseFontFamily]; 9 | const headingTypography: CSSProperties = { 10 | fontFamily: headingFontFamily.join(','), 11 | fontWeight: 'bold', 12 | }; 13 | 14 | const theme = createMuiTheme({ 15 | overrides: { 16 | MuiButton: { 17 | contained: { borderRadius: '2rem' }, 18 | containedPrimary: { 19 | '&:hover': { 20 | backgroundColor: '#fff', 21 | color: primaryColor, 22 | transitionProperty: 'all', 23 | }, 24 | }, 25 | }, 26 | MuiTypography: { 27 | h1: headingTypography, 28 | h2: headingTypography, 29 | h3: headingTypography, 30 | h4: headingTypography, 31 | h5: headingTypography, 32 | h6: headingTypography, 33 | }, 34 | }, 35 | palette: { 36 | primary: { 37 | main: primaryColor, 38 | }, 39 | }, 40 | typography: { 41 | fontFamily: typographyFontFamily.join(','), 42 | fontSize: 12, 43 | }, 44 | }); 45 | 46 | export default theme; 47 | -------------------------------------------------------------------------------- /src/transformers/spreadsheet.transformer.ts: -------------------------------------------------------------------------------- 1 | import { Sheet, Spreadsheet } from 'models'; 2 | 3 | export class SpreadSheetTransformer { 4 | public transform(data: any): Spreadsheet { 5 | const transformedSheets = []; 6 | const sheets = data.sheets; 7 | for (const sheet of sheets) { 8 | const transformedSheet = this.transformSheet(sheet); 9 | transformedSheets.push(transformedSheet); 10 | } 11 | 12 | return { 13 | sheets: transformedSheets, 14 | }; 15 | } 16 | 17 | private transformSheet(sheet: any): Sheet { 18 | const arrayLike = sheet.data[0].rowData.map((row: any) => { 19 | return row.values ? row.values.map((v: any) => v.formattedValue) : []; 20 | }); 21 | 22 | const valuesWithoutEmpties = arrayLike.filter( 23 | (row: any[]) => !row.every((v: any) => !v), 24 | ); 25 | 26 | const title = this.getTitle(valuesWithoutEmpties); 27 | const variables = this.getVariables(valuesWithoutEmpties); 28 | const rawUsers = this.getRawUsers(valuesWithoutEmpties); 29 | 30 | const usersWithVars = rawUsers.map((userValues: any) => { 31 | const transformedUserObject = { title }; 32 | userValues.map((userValue: any, index: number) => { 33 | if (variables[index]) { 34 | // @ts-ignore 35 | transformedUserObject[variables[index]] = userValue || '0'; 36 | } 37 | }); 38 | // @ts-ignore 39 | transformedUserObject.send = true; 40 | return transformedUserObject; 41 | }); 42 | 43 | return { 44 | title: sheet.properties.title, 45 | usersData: usersWithVars, 46 | variables: ['title'].concat(variables), 47 | }; 48 | } 49 | 50 | private getTitle(valuesWithoutEmpties: any) { 51 | return valuesWithoutEmpties[0].join(''); 52 | } 53 | 54 | private getVariables(valuesWithoutEmpties: any) { 55 | return valuesWithoutEmpties[4]; 56 | } 57 | 58 | private getRawUsers(valuesWithoutEmpties: any) { 59 | return valuesWithoutEmpties.slice(5, valuesWithoutEmpties.length); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/transformers/user.transformer.ts: -------------------------------------------------------------------------------- 1 | import { User, UserToken } from 'models'; 2 | 3 | export function createUserFromLocalStorage(storageData: User): User { 4 | const obj: Partial = { 5 | ...storageData, 6 | token: createUserTokenFromLocalStorage(storageData.token), 7 | }; 8 | 9 | return obj as User; 10 | } 11 | 12 | export function createUserTokenFromLocalStorage( 13 | storageData: UserToken, 14 | ): UserToken { 15 | return { 16 | ...storageData, 17 | // Date in LocalStorage is saved as string, we need to recover that back. 18 | expiresAt: new Date((storageData.expiresAt as any) as string), 19 | } as UserToken; 20 | } 21 | 22 | /* JSON transformers */ 23 | export function createUserFromJson(response: any): User { 24 | const obj: Partial = {}; 25 | 26 | obj.email = response.profileObj.email; 27 | obj.lastName = response.profileObj.familyName; 28 | obj.firstName = response.profileObj.givenName; 29 | obj.name = response.profileObj.name; 30 | obj.googleId = response.profileObj.googleId; 31 | obj.token = createUserTokenFromJson(response.tokenObj); 32 | 33 | return obj as User; 34 | } 35 | 36 | export function createUserTokenFromJson(response: any): UserToken { 37 | const obj: Partial = {}; 38 | 39 | obj.accessToken = response.access_token; 40 | obj.expiresAt = response.expires_at && new Date(response.expires_at); 41 | obj.idToken = response.id_token; 42 | 43 | return obj as UserToken; 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/spreadsheet.ts: -------------------------------------------------------------------------------- 1 | import { Nullable, Sheet, Spreadsheet } from 'models'; 2 | 3 | const getSheetByTitle = ( 4 | spreadsheet: Spreadsheet, 5 | title: string, 6 | ): Nullable => { 7 | if (!spreadsheet || !spreadsheet.sheets) { 8 | return null; 9 | } 10 | return ( 11 | spreadsheet.sheets.find((sheet: Sheet) => title === sheet.title) || null 12 | ); 13 | }; 14 | 15 | export { getSheetByTitle }; 16 | -------------------------------------------------------------------------------- /src/utils/testing.tsx: -------------------------------------------------------------------------------- 1 | import ThemeProvider from '@material-ui/styles/ThemeProvider'; 2 | import { render } from '@testing-library/react'; 3 | import { UserContext, UserCtx } from 'context/user'; 4 | import React, { ReactElement } from 'react'; 5 | import theme from 'theme/theme'; 6 | 7 | const defaultUserContext: UserContext = { 8 | removeUser: jest.fn(), 9 | setUser: jest.fn(), 10 | }; 11 | 12 | const contextRender = (userContext: UserContext = defaultUserContext) => ( 13 | element: ReactElement, 14 | ) => { 15 | const app = ( 16 | 17 | {element} 18 | 19 | ); 20 | 21 | return { 22 | render: render(app), 23 | userContext, 24 | }; 25 | }; 26 | 27 | export { contextRender }; 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "target": "es5", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "preserve" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"], 3 | "linterOptions": { 4 | "exclude": [ 5 | "config/**/*.js", 6 | "node_modules/**/*.ts", 7 | "coverage/lcov-report/*.js" 8 | ] 9 | }, 10 | "rules": { 11 | "no-console": { 12 | "severity": "warn" 13 | }, 14 | "ordered-imports": true, 15 | "interface-name": [true, "never-prefix"] 16 | } 17 | } 18 | --------------------------------------------------------------------------------