├── .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 | [](https://travis-ci.org/SoftwareBrothers/mailinger)
2 | [](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 |
--------------------------------------------------------------------------------