├── README.md ├── csv-bulk-signature ├── .gitignore ├── README.md ├── jest.config.js ├── package.json ├── src │ ├── _example-contacts.csv │ ├── config.ts │ ├── email-sig-template.html │ ├── main.test.ts │ ├── main.ts │ ├── safety-checks.ts │ └── types.ts └── yarn.lock └── signature-react-app ├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.test.tsx ├── App.tsx ├── CircularProgressWithLabel.tsx ├── Signature.tsx ├── index.css ├── index.tsx ├── react-app-env.d.ts ├── reportWebVitals.ts └── setupTests.ts ├── tsconfig.json └── yarn.lock /README.md: -------------------------------------------------------------------------------- 1 | # Email-Signature-Generator 2 | We design email signatures at SodiumHalogen.com, this snippet helps our clients make HTML email signatures for their teams. 3 | 4 | ## Two projects in on repo 5 | 6 | 1. **Web app** (React) for one-off signature creations (new hire, update information) 7 | 2. **Node script** (Typescript) for bulk generation of signatures from a CSV to `htm` files. 8 | -------------------------------------------------------------------------------- /csv-bulk-signature/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | contacts.csv 4 | -------------------------------------------------------------------------------- /csv-bulk-signature/README.md: -------------------------------------------------------------------------------- 1 | # Bulk Email Signature Generator 2 | 3 | Process a CSV file to build/download multiple email signatures to local computer in one step. 4 | 5 | Styled .htm signature files contacts are created from a template, consolidated in a `/dist` directory, and zipped up for download. 6 | 7 | ## Steps to run the script 8 | 9 | 1. Place a list of contacts into a `contacts.csv` file and place that file in `/src` directory 10 | 2. In this folder run: `yarn` and then ` yarn bulk` or if you are developing, `y bulk:watch` 11 | 3. Expect to see a `./dist/signatures` folder created that contains all of the successfully processed signatures 12 | 4. All of the processed signature files and a `_statusReport.txt` file will be zipped up into a `/dist/signatures-[DATE/TIME].zip` file 13 | 14 | ## Notes 15 | 16 | 1. Signature formatting may slightly vary based on the email client that you are using. 17 | 2. Contacts in the `/dist/signatures` folder are not automatically deleted when running the script, but are overwritten if a duplicate contact is processed 18 | 3. Signature formatting and styling is provided by the `email-sig-template.html` file CSS and can be modified to change the signature appearance. 19 | -------------------------------------------------------------------------------- /csv-bulk-signature/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; -------------------------------------------------------------------------------- /csv-bulk-signature/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bulk-email-signature-generator", 3 | "version": "1.0.0", 4 | "repository": "git@github.com:sodiumhalogenteam/bulk-email-signature-generator.git", 5 | "author": "SH", 6 | "license": "MIT", 7 | "scripts": { 8 | "bulk": "ts-node src/main.ts", 9 | "bulk:watch": "nodemon src/main.ts" 10 | }, 11 | "volta": { 12 | "node": "18.12.1", 13 | "yarn": "1.22.19" 14 | }, 15 | "dependencies": { 16 | "csv-parse": "^5.3.3", 17 | "handlebars": "^4.7.7", 18 | "inline-css": "^4.0.1", 19 | "jszip": "^3.10.1", 20 | "nodemon": "^2.0.20", 21 | "ts": "^0.2.2", 22 | "ts-node": "^10.9.1", 23 | "typescript": "^4.9.4" 24 | }, 25 | "devDependencies": { 26 | "@jest/globals": "^29.3.1", 27 | "@types/inline-css": "^3.0.1", 28 | "@types/jest": "^29.2.5", 29 | "@types/node": "^18.11.17", 30 | "jest": "^29.3.1", 31 | "ts-jest": "^29.0.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /csv-bulk-signature/src/_example-contacts.csv: -------------------------------------------------------------------------------- 1 | Brand*,Full Name*,Credentials,Title*,Office Phone*,Mobile Phone,Calendly Link 2 | use dropdown to select a brand,ex. Joe Smith,"ex. CPA, CVGA, CGMA",ex. Marketing Director,ex. 731.285.7900,ex. 731.285.7900,ex. https://calendly.com/shjeremy 3 | image-id,First Last,"CPA, CFE",Chief Manager ,777.555.7777,777.444.5555,https://calendly.com/EXAMPLE 4 | -------------------------------------------------------------------------------- /csv-bulk-signature/src/config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * GLOBALS 3 | */ 4 | export const CSV_FILE = "./src/contacts.csv"; 5 | export const CSV_EXPIRATION_DAYS = 1; 6 | export const TEMPLATE_FILE = "./src/email-sig-template.html"; 7 | export const SIGNATURES_PATH = "./dist/signatures"; 8 | export const ZIP_FILE = `${SIGNATURES_PATH}-${ 9 | new Date().toISOString().split(":").join("_").split(".")[0] // date and time 10 | }.zip`; 11 | export const STATUS_REPORT_FILE = `${SIGNATURES_PATH}/_statusReport.txt`; 12 | export const LOGOS = { 13 | "ata-cpa-advisors": 14 | "https://temp-ata-signature-assets.s3.amazonaws.com/ATA_LOGO-CPAAdvisor-BT-RGB.png", 15 | "ata-capital": 16 | "https://temp-ata-signature-assets.s3.amazonaws.com/ATAC_LOGO-BT-RGB.png", 17 | "ata-employment-solutions": 18 | "https://temp-ata-signature-assets.s3.amazonaws.com/ATAES_LOGO-BT-RGB.png", 19 | } as const; 20 | -------------------------------------------------------------------------------- /csv-bulk-signature/src/email-sig-template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 49 | 50 | 51 | 52 | 53 | 67 | 95 | 96 | 97 |
54 | 55 | 56 | 57 | 58 | 63 | 64 | 65 |
59 | 60 | ata-logo 61 | 62 |
66 |
68 | 69 | 70 | 71 | 74 | 75 | 76 | 77 | 78 | 79 | 83 | 84 | 85 | 86 | 91 | 92 | 93 |
72 | {{fullName}}{{#if credentials}}, {{credentials}}{{/if}} 73 |
{{title}}
80 | P: {{officePhone}} 81 | {{#if mobilePhone}}M: {{mobilePhone}}{{/if}} 82 |
87 | {{#if calendly}} 88 | SCHEDULE A MEETING 89 | {{/if}} 90 |
94 |
98 | 99 | -------------------------------------------------------------------------------- /csv-bulk-signature/src/main.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | checkRequiredFields, 3 | filterFilesForZip, 4 | getFileName, 5 | getFullNameFileName, 6 | } from "./main"; 7 | import { Contact } from "./types"; 8 | 9 | const contactGenerator: () => Contact = () => ({ 10 | "Full Name*": "nothing", 11 | Credentials: "nothing", 12 | "Title*": "nothing", 13 | "Office Phone*": "nothing", 14 | "Mobile Phone": "nothing", 15 | "Calendly Link": "nothing", 16 | "Brand*": "ata-capital", 17 | }); 18 | 19 | // test getFullNameFileName 20 | describe("getFullNameFileName", () => { 21 | it("should return the correct file name", () => { 22 | const contact: Contact = { 23 | ...contactGenerator(), 24 | "Full Name*": "John Doe", 25 | }; 26 | const result = getFullNameFileName(contact); 27 | expect(result.fullNameFileName).toBe("john_doe"); 28 | }); 29 | 30 | it("should return file name with a middle name", () => { 31 | const contact: Contact = { 32 | ...contactGenerator(), 33 | "Full Name*": "John Michael Doe", 34 | }; 35 | const result = getFullNameFileName(contact); 36 | expect(result.fullNameFileName).toBe("john_michael_doe"); 37 | }); 38 | }); 39 | 40 | // test getFileName 41 | describe("getFileName", () => { 42 | it("should return file name of first initial + last name", () => { 43 | const contact: Contact = { 44 | ...contactGenerator(), 45 | "Full Name*": "John Doe", 46 | }; 47 | const result = getFileName(contact); 48 | expect(result).toBe("jdoe"); 49 | }); 50 | 51 | it("should return file name with a middle name", () => { 52 | const contact: Contact = { 53 | ...contactGenerator(), 54 | "Full Name*": "John Michael Doe", 55 | }; 56 | const result = getFileName(contact); 57 | expect(result).toBe("jmichaeldoe"); 58 | }); 59 | }); 60 | 61 | describe("filterFilesForZip", () => { 62 | const files = [ 63 | ".gitkeep", 64 | "acurl.htm", 65 | "anitti.htm", 66 | "ndodge.htm", 67 | "_statusReport.txt", 68 | ]; 69 | 70 | const result = files.filter(filterFilesForZip); 71 | 72 | expect(result).toStrictEqual([ 73 | "acurl.htm", 74 | "anitti.htm", 75 | "ndodge.htm", 76 | "_statusReport.txt", 77 | ]); 78 | }); 79 | 80 | describe("checkRequiredFields", () => { 81 | it('should return "true" if all required fields are present', () => { 82 | const result = checkRequiredFields(contactGenerator()); 83 | 84 | expect(result).toBe(true); 85 | }); 86 | 87 | it('should return "false" if any required fields are missing', () => { 88 | expect( 89 | checkRequiredFields({ 90 | ...contactGenerator(), 91 | // @ts-ignore - we're assuming Contact had missing data in runtime 92 | "Brand*": undefined, 93 | }) 94 | ).toBe(false); 95 | 96 | expect( 97 | checkRequiredFields({ 98 | ...contactGenerator(), 99 | // @ts-ignore - we're assuming Contact had missing data in runtime 100 | "Full Name*": undefined, 101 | }) 102 | ).toBe(false); 103 | 104 | expect( 105 | checkRequiredFields({ 106 | ...contactGenerator(), 107 | // @ts-ignore - we're assuming Contact had missing data in runtime 108 | "Office Phone*": undefined, 109 | }) 110 | ).toBe(false); 111 | 112 | expect( 113 | checkRequiredFields({ 114 | ...contactGenerator(), 115 | // @ts-ignore - we're assuming Contact had missing data in runtime 116 | "Title*": undefined, 117 | }) 118 | ).toBe(false); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /csv-bulk-signature/src/main.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "csv-parse"; 2 | import fs from "fs/promises"; 3 | import handlebars from "handlebars"; 4 | import inlineCss from "inline-css"; 5 | import jszip from "jszip"; 6 | import { 7 | CSV_FILE, 8 | LOGOS, 9 | SIGNATURES_PATH, 10 | STATUS_REPORT_FILE, 11 | TEMPLATE_FILE, 12 | ZIP_FILE, 13 | } from "./config"; 14 | import { 15 | checkContactsFileCreatedAt, 16 | checkFileCountWithCsvCount, 17 | checkHeadersToBeSame, 18 | checkZipIsNotEmpty, 19 | hasContactsFile, 20 | isPath, 21 | } from "./safety-checks"; 22 | import { TemplateData, Contact } from "./types"; 23 | 24 | /* 25 | * MAIN FUNCTIONS 26 | */ 27 | async function setupFolders() { 28 | await createDirIfMissing("./dist"); 29 | await createDirIfMissing("./dist/signatures"); 30 | await deleteOldSignatures(); 31 | } 32 | 33 | async function createDirIfMissing(path: string) { 34 | if (!(await isPath(path))) { 35 | await fs.mkdir(path); 36 | } 37 | } 38 | 39 | async function deleteOldSignatures() { 40 | const signaturesFolder = await fs.readdir(SIGNATURES_PATH); 41 | if (signaturesFolder.length > 0) { 42 | let count = 0; 43 | for await (const file of signaturesFolder) { 44 | await fs.unlink(`${SIGNATURES_PATH}/${file}`); 45 | 46 | count++; 47 | } 48 | 49 | console.log(`🗑️ CLEAN UP: Deleted ${count} old signatures`); 50 | } 51 | } 52 | 53 | async function setupTemplate() { 54 | const templateFile = await fs.readFile(TEMPLATE_FILE, "utf8"); 55 | 56 | const inlinedTemplate = await inlineCss(templateFile, { 57 | url: "./", 58 | removeHtmlSelectors: true, 59 | }); 60 | 61 | return handlebars.compile(inlinedTemplate); 62 | } 63 | 64 | async function generateSignatures( 65 | template: HandlebarsTemplateDelegate, 66 | contacts: Contact[] 67 | ) { 68 | console.log("📝 Generating signatures..."); 69 | for await (const contact of contacts) { 70 | if (contact.skip) return; // skip if missing required fields 71 | 72 | const html = template({ 73 | logoUrl: LOGOS[contact["Brand*"]], 74 | fullName: contact["Full Name*"], 75 | credentials: contact["Credentials"], 76 | title: contact["Title*"], 77 | officePhone: contact["Office Phone*"], 78 | mobilePhone: contact["Mobile Phone"], 79 | calendly: contact["Calendly Link"], 80 | }); 81 | 82 | const fileName = getFileName(contact); 83 | await createSignatureFile(fileName, contact, html); 84 | } 85 | 86 | console.log("👍 Signatures generated"); 87 | } 88 | 89 | async function createSignatureFile( 90 | fileName: string, 91 | contact: Contact, 92 | html: string 93 | ) { 94 | const { fullNameFileName, fullName } = getFullNameFileName(contact); 95 | const filePath = `${SIGNATURES_PATH}/${fileName}.htm`; 96 | const fullNameFilePath = `${SIGNATURES_PATH}/${fullNameFileName}.htm`; 97 | 98 | // check if files exists 99 | const hasFilePath = await isPath(filePath); 100 | const hasFullNameFile = await isPath(fullNameFilePath); 101 | 102 | if (!hasFilePath) { 103 | return createFile(filePath, html); 104 | } 105 | 106 | if (!hasFullNameFile) { 107 | return createFileWithFullName(fullNameFilePath, fullName, fileName, html); 108 | } 109 | 110 | console.error( 111 | ` 🔴 ERROR: For ${fullName}, ${fileName}.htm and ${fullNameFileName}.htm already exist.` 112 | ); 113 | } 114 | 115 | async function createFile(filePath: string, html: string) { 116 | await fs.writeFile(filePath, html); 117 | } 118 | 119 | async function createFileWithFullName( 120 | fullNameFilePath: string, 121 | fullName: string, 122 | fileName: string, 123 | html: string 124 | ) { 125 | await createFile(fullNameFilePath, html); 126 | console.error( 127 | ` 🟡 WARNING: For ${fullName}, ${fileName}.htm already exists. Instead ${fileName}.htm was created.` 128 | ); 129 | } 130 | 131 | export function getFullNameFileName(contact: Contact) { 132 | const fullName = contact["Full Name*"]; 133 | const lowercaseFullName = fullName.toLowerCase(); 134 | const fullNameFileName = lowercaseFullName.replace(/\s/g, "_"); 135 | return { fullNameFileName, fullName }; 136 | } 137 | 138 | export function getFileName(contact: Contact) { 139 | const nameSplit = contact["Full Name*"].split(" "); 140 | const firstInitial = nameSplit[0].charAt(0); 141 | 142 | // if has middle name, join the rest of the name 143 | const hasMiddleName = contact["Full Name*"].split(" ").length > 2; 144 | const lastName = hasMiddleName ? nameSplit.slice(1).join("") : nameSplit[1]; 145 | 146 | const fileName = `${firstInitial}${lastName}`; 147 | const lowerCaseFullName = fileName.toLowerCase(); 148 | return lowerCaseFullName; 149 | } 150 | 151 | export async function createZipFile(files: string[]) { 152 | const zip = new jszip(); 153 | 154 | for await (const file of files) { 155 | const filePath = `${SIGNATURES_PATH}/${file}`; 156 | const fileContents = await fs.readFile(filePath); 157 | zip.file(file, fileContents); 158 | } 159 | 160 | return zip.generateAsync({ type: "nodebuffer" }); 161 | } 162 | 163 | export async function zipUpFiles(files: string[]) { 164 | try { 165 | const buffer = await createZipFile(files); 166 | await fs.writeFile(ZIP_FILE, buffer); 167 | 168 | console.log("👍 Zip file created (signatures + report)"); 169 | console.log("🗜", ZIP_FILE); 170 | } catch (error) { 171 | console.error(error); 172 | } 173 | } 174 | 175 | async function createStatusReport(contacts: Contact[]) { 176 | const skippedRows: string[] = []; 177 | const processedRows: string[] = []; 178 | 179 | // 1. collect skipped and processed rows 180 | for (const contact of contacts) { 181 | if (contact.skip) { 182 | skippedRows.push(contact["Full Name*"]); 183 | } else { 184 | processedRows.push(contact["Full Name*"]); 185 | } 186 | } 187 | 188 | // 2. report in console 189 | console.log( 190 | `${skippedRows.length ? "🔴" : "👍"} Signatures:`, 191 | processedRows.length, 192 | `processed and`, 193 | skippedRows.length, 194 | `skipped` 195 | ); 196 | 197 | // 3. report in file 198 | skippedRows.unshift(`\nROWS/SIGNATURES SKIPPED: (${skippedRows.length}) \n`); 199 | processedRows.unshift( 200 | `\nROWS/SIGNATURES PROCESSED SUCCESSFULLY: (${processedRows.length}) \n` 201 | ); 202 | const combinedStatus = skippedRows.concat(processedRows); 203 | await fs.writeFile(STATUS_REPORT_FILE, combinedStatus.join("\n")); 204 | 205 | console.log("👍 Report created"); 206 | } 207 | 208 | async function getCSVRows(path: string) { 209 | return new Promise(async (resolve: (arg: Contact[]) => void) => { 210 | const file = await fs.readFile(path); 211 | 212 | parse(file, { columns: true }, function (err, rows) { 213 | resolve(rows as Contact[]); 214 | }); 215 | }); 216 | } 217 | 218 | function skipFirstPlaceholderRow(contact: Contact) { 219 | // @ts-ignore - a hack to skip the first row 220 | return contact["Brand*"] !== "use dropdown to select a brand"; 221 | } 222 | 223 | export function filterFilesForZip(file: string) { 224 | return file.endsWith(".htm") || file.endsWith(".txt"); 225 | } 226 | 227 | export const checkRequiredFields = (row: Contact) => 228 | !!row["Brand*"]?.length && 229 | !!row["Full Name*"]?.length && 230 | !!row["Title*"]?.length && 231 | !!row["Office Phone*"]?.length; 232 | 233 | /* 234 | * MAIN 235 | */ 236 | async function main() { 237 | // 1. setup folders + template 238 | await setupFolders(); 239 | const template = await setupTemplate(); 240 | 241 | // 2. check contacts file 242 | if (!(await hasContactsFile())) return; 243 | await checkContactsFileCreatedAt(); 244 | 245 | // 3. get contacts + filter 246 | const contacts = await getCSVRows(CSV_FILE); 247 | await checkHeadersToBeSame(contacts[0]); 248 | 249 | const contactsWithNewProps = contacts 250 | .filter(skipFirstPlaceholderRow) 251 | .map((contact) => ({ ...contact, skip: !checkRequiredFields(contact) })); 252 | 253 | // 4. generate signatures 254 | await generateSignatures(template, contactsWithNewProps); 255 | await checkFileCountWithCsvCount(contactsWithNewProps); 256 | 257 | // 5. create report 258 | await createStatusReport(contactsWithNewProps); 259 | 260 | // 6. gather files for zip 261 | const signatureFolder = await fs.readdir(SIGNATURES_PATH); 262 | const files = signatureFolder.filter(filterFilesForZip); 263 | 264 | // 7. zip up files 265 | await zipUpFiles(files); 266 | await checkZipIsNotEmpty(ZIP_FILE); 267 | } 268 | 269 | main().catch((err) => { 270 | console.log({ err }); 271 | }); 272 | -------------------------------------------------------------------------------- /csv-bulk-signature/src/safety-checks.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs/promises"; 2 | import { SIGNATURES_PATH, CSV_FILE, CSV_EXPIRATION_DAYS } from "./config"; 3 | import { Contact } from "./types"; 4 | 5 | export async function checkFileCountWithCsvCount( 6 | contactsWithNewProps: Contact[] 7 | ) { 8 | // get files in signatures folder 9 | const signaturesFolder = await fs.readdir(SIGNATURES_PATH); 10 | const signatures = signaturesFolder.filter((file) => file.endsWith(".htm")); 11 | const signaturesCount = signatures.length; 12 | 13 | // get contacts count 14 | const contactsCount = contactsWithNewProps.filter( 15 | (contact) => !contact.skip 16 | ).length; 17 | 18 | const isCountSame = signaturesCount === contactsCount; 19 | 20 | if (!isCountSame) { 21 | console.error( 22 | `\u001b[31m\u001b[1mError: \u001b[0m\u001b[31mSignatures count (${signaturesCount}) does not match contacts count (${contactsCount}). Please check the contacts.csv file or zipFilePath() and try again.\u001b[0m` 23 | ); 24 | } else { 25 | console.log("👍 Signatures count matches CSV count"); 26 | } 27 | } 28 | 29 | export async function isPath(path: string, catchCallback?: () => void) { 30 | return await fs 31 | .stat(path) 32 | .then(() => true) 33 | .catch(() => { 34 | if (catchCallback) catchCallback(); 35 | return false; 36 | }); 37 | } 38 | 39 | export async function hasContactsFile() { 40 | return await isPath(CSV_FILE, () => 41 | console.log("🔴 contacts.csv file does not exist") 42 | ); 43 | } 44 | 45 | export async function checkContactsFileCreatedAt() { 46 | const stats = await fs.stat(CSV_FILE); 47 | const createdAt = stats.birthtime; 48 | const now = new Date(); 49 | const diff = Math.abs(now.getTime() - createdAt.getTime()); 50 | const diffDays = Math.ceil(diff / (1000 * 3600 * 24)); 51 | 52 | if (diffDays > CSV_EXPIRATION_DAYS) { 53 | console.log( 54 | `🔴 CSV contacts file is older than ${CSV_EXPIRATION_DAYS} day(s). Please update the file.` 55 | ); 56 | } else { 57 | console.log( 58 | `👍 CSV contacts file is less than ${CSV_EXPIRATION_DAYS} day(s) old` 59 | ); 60 | } 61 | } 62 | 63 | export async function checkZipIsNotEmpty(zipFilePath: string) { 64 | const stats = await fs.stat(zipFilePath); 65 | if (stats.size <= 22) { 66 | console.error( 67 | `\u001b[31m\u001b[1mError: \u001b[0m\u001b[31mZip file is empty. Please check the contacts.csv file or zipFilePath() and try again.\u001b[0m` 68 | ); 69 | } 70 | } 71 | 72 | export async function checkHeadersToBeSame(contact: Contact) { 73 | const expectedHeaders = [ 74 | "Brand*", 75 | "Full Name*", 76 | "Credentials", 77 | "Title*", 78 | "Office Phone*", 79 | "Mobile Phone", 80 | "Calendly Link", 81 | ]; 82 | 83 | const hasExpectedHeaders = expectedHeaders.every((expectedHeader) => 84 | contact.hasOwnProperty(expectedHeader) 85 | ); 86 | 87 | if (hasExpectedHeaders) { 88 | console.log("👍 CSV Headers match"); 89 | } else { 90 | throw new Error("🔴 CSV Headers do not match"); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /csv-bulk-signature/src/types.ts: -------------------------------------------------------------------------------- 1 | import { LOGOS } from "./config"; 2 | 3 | export interface Contact { 4 | "Brand*": keyof typeof LOGOS; 5 | "Full Name*": string; 6 | Credentials: string; 7 | "Title*": string; 8 | "Office Phone*": string; 9 | "Mobile Phone": string; 10 | "Calendly Link": string; 11 | skip?: boolean; 12 | } 13 | export interface TemplateData { 14 | logoUrl: string; 15 | fullName: string; 16 | credentials: string; 17 | title: string; 18 | officePhone: string; 19 | mobilePhone: string; 20 | calendly: string; 21 | } 22 | -------------------------------------------------------------------------------- /signature-react-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .idea -------------------------------------------------------------------------------- /signature-react-app/README.md: -------------------------------------------------------------------------------- 1 | # Simple SPA for email-signature generation. 2 | 3 | [Original medium post](https://al3xsus.medium.com/how-to-create-a-signature-generating-app-with-react-ffeb2f2201cc) 4 | 5 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). This project also 6 | use Material-UI and Typescript. 7 | 8 | ## Available Scripts 9 | 10 | In the project directory, you can run: 11 | 12 | ### `yarn start` 13 | 14 | Runs the app in the development mode.\ 15 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 16 | 17 | ** The page may need to be reloaded if you make edits. ** \ 18 | You will also see any lint errors in the console. 19 | 20 | ### `yarn test` 21 | 22 | Launches the test runner in the interactive watch mode.\ 23 | 24 | ### `yarn build` 25 | 26 | Builds the app for production to the `build` folder.\ 27 | 28 | ### `yarn eject` 29 | 30 | # Deploy and development notes 31 | 32 | I've used Node v12.14.1 33 | 34 | ## How it works 35 | 36 | Just input your data and you'll get the result. Note that required fields are marked with an '\*'. After all required fields have been filled, you can copy the signature to the clipboard, or download the signature html file to your computer. 37 | 38 | ## Screenshots 39 | 40 | Main page 41 | 42 | ![Main page](https://temp-ata-signature-assets.s3.amazonaws.com/main-page.png "Main page") 43 | 44 | Signature with logo 45 | 46 | ![Signature with logo](https://temp-ata-signature-assets.s3.amazonaws.com/signature-with-logo.png "Signature with logo") 47 | 48 | ## Tips, Tricks, & Guides 49 | 50 | [ATA Signature App Walkthrough Video](https://temp-ata-signature-assets.s3.amazonaws.com/ATA-Signature-Generator-Walkthrough.mp4) 51 | 52 | [Outlook 2020 Signature Guide](https://www.hubspot.com/email-signature-generator/add-signature-outlook) 53 | 54 | [Outlook 2016 Video](https://temp-ata-signature-assets.s3.amazonaws.com/add-file-signature-outlook-2016.mp4) 55 | 56 | ![Outlook 2016 Screenshot](https://temp-ata-signature-assets.s3.amazonaws.com/edit-signatures-outlook-2016.png "Signature with logo") 57 | -------------------------------------------------------------------------------- /signature-react-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "A simple email signature generator app (SPA) made with React", 3 | "keywords": [ 4 | "spa", 5 | "react", 6 | "material-ui", 7 | "email-signature" 8 | ], 9 | "license": "MIT", 10 | "name": "email-signature-generator", 11 | "private": true, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/al3xsus/email-signature-generator" 15 | }, 16 | "version": "0.1.0", 17 | "dependencies": { 18 | "@emotion/react": "^11.10.5", 19 | "@emotion/styled": "^11.10.5", 20 | "@material-ui/core": "^4.11.3", 21 | "@material-ui/icons": "^4.11.2", 22 | "@mui/icons-material": "^5.11.0", 23 | "@mui/material": "^5.11.2", 24 | "@testing-library/jest-dom": "^5.11.4", 25 | "@testing-library/react": "^11.1.0", 26 | "@testing-library/user-event": "^12.1.10", 27 | "@types/jest": "^26.0.15", 28 | "@types/node": "^12.0.0", 29 | "@types/react": "^17.0.0", 30 | "@types/react-dom": "^17.0.0", 31 | "gh-pages": "^3.1.0", 32 | "react": "^17.0.2", 33 | "react-dom": "^17.0.2", 34 | "react-scripts": "4.0.3", 35 | "typescript": "^4.1.2", 36 | "web-vitals": "^1.0.1" 37 | }, 38 | "scripts": { 39 | "start": "react-scripts start", 40 | "build": "react-scripts build", 41 | "test": "react-scripts test", 42 | "eject": "react-scripts eject", 43 | "predeploy": "yarn build", 44 | "deploy": "gh-pages -d build" 45 | }, 46 | "eslintConfig": { 47 | "extends": [ 48 | "react-app", 49 | "react-app/jest" 50 | ] 51 | }, 52 | "browserslist": { 53 | "production": [ 54 | ">0.2%", 55 | "not dead", 56 | "not op_mini all" 57 | ], 58 | "development": [ 59 | "last 1 chrome version", 60 | "last 1 firefox version", 61 | "last 1 safari version" 62 | ] 63 | }, 64 | "volta": { 65 | "node": "16.19.0", 66 | "yarn": "1.22.19" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /signature-react-app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chancesmith/Email-Signature-Generator/2bbd9be4ff17468137c917be71912ad77f82c1ab/signature-react-app/public/favicon.ico -------------------------------------------------------------------------------- /signature-react-app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 18 | 19 | 28 | ATA Email Signature Generator 29 | 30 | 31 | 32 |
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /signature-react-app/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chancesmith/Email-Signature-Generator/2bbd9be4ff17468137c917be71912ad77f82c1ab/signature-react-app/public/logo192.png -------------------------------------------------------------------------------- /signature-react-app/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chancesmith/Email-Signature-Generator/2bbd9be4ff17468137c917be71912ad77f82c1ab/signature-react-app/public/logo512.png -------------------------------------------------------------------------------- /signature-react-app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Signature App", 3 | "name": "Email Signature Generator", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "ATA_LOGO-B-RGB.png", 12 | "type": "image/png", 13 | "sizes": "192x103" 14 | }, 15 | { 16 | "src": "ATA_LOGO-B-RGB.png", 17 | "type": "image/png", 18 | "sizes": "512x274" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /signature-react-app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /signature-react-app/src/App.css: -------------------------------------------------------------------------------- 1 | a { 2 | color: #4899d5; 3 | font-family: helvetica, bold; 4 | font-weight: bold; 5 | font-size: 14px; 6 | text-decoration: none; 7 | text-transform: uppercase; 8 | } 9 | 10 | td { 11 | vertical-align: top; 12 | padding-bottom: 5px; 13 | } 14 | 15 | .align-bottom { 16 | height: 60%; 17 | vertical-align: bottom; 18 | } 19 | 20 | .table-height { 21 | height: 100%; 22 | } 23 | 24 | .signature { 25 | height: 100px; 26 | max-width: 100%; 27 | white-space: nowrap; 28 | background: #FFFFFF; 29 | font-family: Arial, Helvetica, sans-serif; 30 | } 31 | 32 | .blue-bold-text { 33 | color: #4899d5; 34 | font-family: helvetica, bold; 35 | font-weight: bold; 36 | font-size: 14px; 37 | } 38 | 39 | .regular-text { 40 | color: #A2A8AB; 41 | font-family: helvetica; 42 | font-size: 14px; 43 | } 44 | 45 | .main-image { 46 | width: 120px; 47 | height: 90px; 48 | } -------------------------------------------------------------------------------- /signature-react-app/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, screen} from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /signature-react-app/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Container, 5 | FormControl, 6 | InputLabel, 7 | MenuItem, 8 | TextField, 9 | Typography, 10 | } from "@material-ui/core"; 11 | import Grid from "@material-ui/core/Grid"; 12 | import Paper from "@material-ui/core/Paper"; 13 | import { Theme, createStyles, makeStyles } from "@material-ui/core/styles"; 14 | import { CheckOutlined, FileCopyOutlined } from "@material-ui/icons"; 15 | import { Select, SelectChangeEvent } from "@mui/material"; 16 | import React from "react"; 17 | import "./App.css"; 18 | import ReactDOMServer from "react-dom/server"; 19 | import DownloadIcon from "@mui/icons-material/Download"; 20 | import CircularProgressWithLabel from "./CircularProgressWithLabel"; 21 | import Signature from "./Signature"; 22 | 23 | const useStyles = makeStyles((theme: Theme) => 24 | // Styles for the web app 25 | createStyles({ 26 | root: { 27 | "& .MuiTextField-root": { 28 | margin: theme.spacing(1), 29 | }, 30 | "& .label-root": { 31 | margin: theme.spacing(1), 32 | }, 33 | }, 34 | paper: { 35 | padding: theme.spacing(2), 36 | textAlign: "left", 37 | color: theme.palette.text.secondary, 38 | }, 39 | centeredImage: { 40 | display: "block", 41 | marginLeft: "auto", 42 | marginRight: "auto", 43 | marginTop: "1rem", 44 | width: "150px", 45 | }, 46 | centeredText: { 47 | textAlign: "center", 48 | }, 49 | warningIconStyle: { 50 | textAlign: "center", 51 | color: "#FFDC00", 52 | verticalAlign: "middle", 53 | }, 54 | box: { 55 | width: "75%", 56 | }, 57 | inputLabel: { 58 | marginLeft: 10, 59 | marginTop: 3, 60 | }, 61 | select: { 62 | width: 250, 63 | height: 50, 64 | marginLeft: 0.7, 65 | }, 66 | }) 67 | ); 68 | 69 | export const LOGOS = { 70 | "ata-cpa-advisors": 71 | "https://temp-ata-signature-assets.s3.amazonaws.com/ATA_LOGO-CPAAdvisor-BT-RGB.png", 72 | "ata-capital": 73 | "https://temp-ata-signature-assets.s3.amazonaws.com/ATAC_LOGO-BT-RGB.png", 74 | "ata-employment-solutions": 75 | "https://temp-ata-signature-assets.s3.amazonaws.com/ATAES_LOGO-BT-RGB.png", 76 | } as const; 77 | 78 | export interface PhotoSignatureProps { 79 | logo: keyof typeof LOGOS; 80 | fullName: string; 81 | credentials: string; 82 | title: string; 83 | phone: string; 84 | mobile: string; 85 | calendlyLink: string; 86 | } 87 | 88 | interface State extends PhotoSignatureProps { 89 | copied: boolean; 90 | } 91 | 92 | const initialState: State = { 93 | logo: "ata-capital", 94 | fullName: "", 95 | credentials: "", 96 | title: "", 97 | phone: "", 98 | mobile: "", 99 | calendlyLink: "", 100 | copied: false, 101 | }; 102 | 103 | function App() { 104 | const classes = useStyles(); 105 | const [state, setState] = React.useState(initialState); 106 | 107 | const hasRequiredFields: boolean = 108 | !!state.logo && !!state.fullName && !!state.title && !!state.phone; 109 | 110 | React.useEffect(() => { 111 | setState(initialState); 112 | }, []); 113 | 114 | const handleChange = (event: React.ChangeEvent) => { 115 | setState((prevState) => ({ 116 | ...prevState, 117 | [event.target.name]: event.target.value, 118 | })); 119 | }; 120 | 121 | const handleChangeLogo = ( 122 | event: SelectChangeEvent< 123 | "ata-cpa-advisors" | "ata-capital" | "ata-employment-solutions" 124 | > 125 | ) => { 126 | setState((prevState) => ({ 127 | ...prevState, 128 | [event.target.name]: event.target.value, 129 | })); 130 | }; 131 | 132 | //signature will not show in the preview until the first bit of data is added 133 | const showSignature = () => { 134 | let progress = 0; 135 | 136 | if (state.fullName) { 137 | return ( 138 | 139 | 148 |
149 | 156 | 163 |
164 | ); 165 | } 166 | if (progress > 0) { 167 | return ( 168 |
169 | 170 |
171 | ); 172 | } else { 173 | return
Please, input your data
; 174 | } 175 | }; 176 | 177 | const copyToClipboard = () => { 178 | let copyText = document.querySelector(".signature"); 179 | const range = document.createRange(); 180 | if (copyText) { 181 | range.selectNode(copyText); 182 | } 183 | const windowSelection = window.getSelection(); 184 | if (windowSelection) { 185 | windowSelection.removeAllRanges(); 186 | windowSelection.addRange(range); 187 | } 188 | try { 189 | let successful = document.execCommand("copy"); 190 | console.log(successful ? "Success" : "Fail"); 191 | setState((prevState) => ({ 192 | ...prevState, 193 | copied: true, 194 | })); 195 | } catch (err) { 196 | console.log("Fail"); 197 | } 198 | }; 199 | 200 | const downloadHtmlFile = () => { 201 | const htmlSignature = ReactDOMServer.renderToStaticMarkup( 202 | 211 | ); 212 | const lowerCaseName = state.fullName.toLowerCase(); 213 | const nameSplit = lowerCaseName.split(" "); 214 | const firstInitial = nameSplit[0].charAt(0); 215 | const lastName = nameSplit[1]; 216 | const blob = new Blob([htmlSignature]); 217 | const fileDownloadUrl = URL.createObjectURL(blob); 218 | const link = document.createElement("a"); 219 | link.href = fileDownloadUrl; 220 | link.setAttribute("download", `${firstInitial}${lastName}.htm`); 221 | document.body.appendChild(link); 222 | link.click(); 223 | link.parentNode?.removeChild(link); 224 | }; 225 | 226 | const isStateChanged = () => { 227 | return JSON.stringify(state) === JSON.stringify(initialState); 228 | }; 229 | 230 | const clearState = () => { 231 | setState(initialState); 232 | }; 233 | 234 | return ( 235 | 236 | {"ata-logo"} 241 | 242 | Signature generator 243 | 244 | 249 | 250 | 251 | 252 |
253 | 254 | 255 | 260 | Choose a Logo 261 | 262 | 279 | 280 | 281 | 290 | 297 | 305 | 314 | 322 | 329 |
330 | 337 | 338 |
339 |
340 | 341 | {showSignature()} 342 | 343 |
344 |
345 | ); 346 | } 347 | 348 | export default App; 349 | -------------------------------------------------------------------------------- /signature-react-app/src/CircularProgressWithLabel.tsx: -------------------------------------------------------------------------------- 1 | import {Box, CircularProgress, CircularProgressProps, Typography,} from "@material-ui/core"; 2 | 3 | export default function CircularProgressWithLabel( 4 | props: CircularProgressProps & { value: number } 5 | ) { 6 | return ( 7 | 8 | 9 | 19 | {`${Math.round(props.value)}%`} 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /signature-react-app/src/Signature.tsx: -------------------------------------------------------------------------------- 1 | import { PhotoSignatureProps } from "./App"; 2 | 3 | const Signature = (props: PhotoSignatureProps) => { 4 | return ( 5 | /*Container table */ 6 | 17 | 18 | 19 | 37 | 96 | 97 | 98 |
20 | {/* table containing the logo image */} 21 | 22 | 23 | 24 | 33 | 34 | 35 |
25 | 26 | {"ata-logo"} 31 | 32 |
36 |
38 | {/* table containing the text content */} 39 | 40 | 41 | 42 | 54 | 55 | 56 | 65 | 66 | 67 | 79 | 80 | 81 | {/* the class 'align-bottom' also controls the height of the row that this cell inhabits */} 82 | 92 | 93 | 94 |
50 | {props.fullName} 51 | {props.credentials === "" ? "" : ", "} 52 | {props.credentials === "" ? "" : props.credentials} 53 |
63 | {props.title} 64 |
74 | {props.phone === "" ? "" : "P: "} 75 | {props.phone === "" ? "" : props.phone} 76 | {props.mobile === "" ? "" : " M:"} 77 | {props.mobile === "" ? "" : props.mobile} 78 |
83 | {/* if props.calendlyLink is blank there will be nothing in this cell */} 84 | 89 | {props.calendlyLink === "" ? "" : "SCHEDULE A MEETING"} 90 | 91 |
95 |
99 | ); 100 | }; 101 | 102 | export default Signature; 103 | -------------------------------------------------------------------------------- /signature-react-app/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | background-color: #F2F2F2; 9 | } -------------------------------------------------------------------------------- /signature-react-app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /signature-react-app/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /signature-react-app/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import {ReportHandler} from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({getCLS, getFID, getFCP, getLCP, getTTFB}) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /signature-react-app/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; -------------------------------------------------------------------------------- /signature-react-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------