├── addOn ├── .claspignore ├── src │ ├── types.ts │ ├── config.ts │ ├── index.ts │ ├── props.ts │ ├── db.ts │ ├── common.ts │ └── gmail.ts ├── .babelrc ├── tsconfig.json ├── .eslintrc.json ├── appsscript.json ├── package.json └── webpack.config.js ├── functions ├── globals.d.ts ├── config │ ├── production.json │ └── default.json ├── firebase.json ├── utils │ ├── firebase.ts │ └── stackDriverLogger.ts ├── src │ ├── hashRouter.ts │ ├── trialExtender.ts │ ├── index.ts │ └── subscriptionWebhook.ts ├── tsconfig.json ├── deploy.sh ├── package.json └── webpack.config.js ├── .prettierrc ├── .gitignore └── readme.md /addOn/.claspignore: -------------------------------------------------------------------------------- 1 | main.js 2 | *-impl.html -------------------------------------------------------------------------------- /functions/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare const CONFIG: any 2 | -------------------------------------------------------------------------------- /addOn/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface AppState { 2 | user: any 3 | } 4 | -------------------------------------------------------------------------------- /functions/config/production.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": "production" 3 | } 4 | -------------------------------------------------------------------------------- /functions/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "source": "./dist" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": false, 4 | "singleQuote": false, 5 | "trailingComma": "none", 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /functions/utils/firebase.ts: -------------------------------------------------------------------------------- 1 | import * as adminSDK from 'firebase-admin' 2 | 3 | adminSDK.initializeApp({ 4 | credential: adminSDK.credential.cert(CONFIG.firebase) 5 | }) 6 | 7 | export const storage = adminSDK.storage() 8 | export const auth = adminSDK.auth() 9 | export const firestore = adminSDK.firestore() 10 | export const admin = adminSDK 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # clasp files 2 | .clasp* 3 | creds.json 4 | 5 | # npm 6 | node_modules 7 | 8 | # vs code settings 9 | .vscode 10 | 11 | # caches 12 | *.cache 13 | 14 | # certs 15 | *.pem 16 | certs/ 17 | 18 | # build 19 | **/dist/ 20 | dist/ 21 | 22 | # mac 23 | .DS_Store 24 | 25 | # misc 26 | .cache 27 | yarn-error.log 28 | .firebase 29 | .well-known/ 30 | *.log 31 | *.lock 32 | -------------------------------------------------------------------------------- /functions/src/hashRouter.ts: -------------------------------------------------------------------------------- 1 | import express from "express" 2 | import crypto from "crypto" 3 | 4 | const router = express.Router() 5 | router.get("/", (req, res) => { 6 | const { email }: any = req.query 7 | const token = crypto.createHmac("sha256", CONFIG.billing.secretKey).update(email).digest("hex") 8 | return res.status(200).send({ token }) 9 | }) 10 | 11 | export default router 12 | -------------------------------------------------------------------------------- /addOn/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "modules": false 7 | } 8 | ] 9 | ], 10 | "plugins": [ 11 | "transform-es3-property-literals", 12 | "transform-es3-member-expression-literals", 13 | "@babel/plugin-proposal-class-properties", 14 | "@babel/plugin-proposal-object-rest-spread", 15 | "@babel/plugin-transform-object-assign" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /addOn/src/config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | firebaseSecret: { 3 | type: "service_account", 4 | project_id: "appname", 5 | private_key_id: "111", 6 | private_key: "111", 7 | client_email: "appname", 8 | client_id: "111", 9 | auth_uri: "https://appname", 10 | token_uri: "https://appname", 11 | auth_provider_x509_cert_url: "https://www.appname", 12 | client_x509_cert_url: "https://www.appname" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /addOn/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["google-apps-script", "node"], 4 | "target": "es5", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true, 8 | "allowJs": false, 9 | "sourceMap": true, 10 | "noImplicitAny": false, 11 | "experimentalDecorators": true, 12 | "allowSyntheticDefaultImports": true, 13 | "forceConsistentCasingInFileNames": false, 14 | "lib": ["es6", "dom"] 15 | }, 16 | "exclude": ["node_modules", "dist"] 17 | } 18 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "resolveJsonModule": true, 7 | "jsx": "react", 8 | "allowJs": false, 9 | "sourceMap": true, 10 | "noImplicitAny": false, 11 | "experimentalDecorators": true, 12 | "allowSyntheticDefaultImports": true, 13 | "forceConsistentCasingInFileNames": false, 14 | "lib": ["es6", "dom"], 15 | "baseUrl": "./", 16 | "paths": { 17 | "src": ["src"], 18 | "utils": ["src/utils"] 19 | } 20 | }, 21 | "exclude": ["node_modules", "dist"] 22 | } 23 | -------------------------------------------------------------------------------- /functions/src/trialExtender.ts: -------------------------------------------------------------------------------- 1 | import { firestore } from "../utils/firebase" 2 | 3 | export default (app) => { 4 | app.post("/", (req, res) => { 5 | const { email } = req.body 6 | // Push recipient read event 7 | return firestore 8 | .collection("users") 9 | .doc(email) 10 | .get() 11 | .then((doc) => { 12 | if (!doc.exists) return res.sendStatus(400) 13 | firestore.collection("users")?.doc(email).update({ 14 | trialExtended: Date.now() 15 | }) 16 | return res.sendStatus(200) 17 | }) 18 | .catch((e) => { 19 | console.error("Error:", e) 20 | }) 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /functions/config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": "development", 3 | 4 | "billing": { 5 | "secretKey": "billing_sk_test_xxxx" 6 | }, 7 | 8 | "stripe": { 9 | "secretKey": "sk_test_xxxx", 10 | "webhookSecret": "whsec_xxxx" 11 | }, 12 | 13 | "firebase": { 14 | "type": "service_account", 15 | "project_id": "xxxxx", 16 | "private_key_id": "0000", 17 | "private_key": "-----BEGIN PRIVATE KEY-----xxxxxxxx-----END PRIVATE KEY-----\n", 18 | "client_email": "firebase-xxxx@iam.gserviceaccount.com", 19 | "client_id": "11111", 20 | "auth_uri": "https://auth", 21 | "token_uri": "https://token", 22 | "auth_provider_x509_cert_url": "https://certs", 23 | "client_x509_cert_url": "https://www.googleapis.com/gserviceaccount.com" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /addOn/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as common from "./common" 2 | import * as gmail from "./gmail" 3 | import * as props from "./props" 4 | import * as db from "./db" 5 | 6 | declare const global: any 7 | 8 | // COMMON 9 | global.onOpen = (e: any) => common.createCatCard("good morning", true) 10 | global.createCatCard = common.createCatCard 11 | global.onChangeCat = common.onChangeCat 12 | 13 | // GMAIL 14 | global.onGmailMessage = gmail.onGmailMessage 15 | global.onGmailCompose = gmail.onGmailCompose 16 | global.onGmailInsertCat = gmail.onGmailInsertCat 17 | 18 | // PROPS 19 | global.setUserProperties = props.setUserProperties 20 | global.getUserProperties = props.getUserProperties 21 | global.deleteUserProperties = props.deleteUserProperties 22 | global.deleteScriptProperties = props.deleteScriptProperties 23 | 24 | // DB 25 | global.fetchUserData = db.fetchUserData 26 | -------------------------------------------------------------------------------- /functions/deploy.sh: -------------------------------------------------------------------------------- 1 | BRANCH=$(git rev-parse --abbrev-ref HEAD) 2 | 3 | echo "🚀 Write 'prod' to deploy in production" 4 | read prod 5 | 6 | if [[ ! -z "$prod" ]] && [ $prod = "prod" ]; then 7 | echo "\n\n 🔥 🔥 🔥 🔥 🔥 🔥 🔥 🔥 🔥 🔥" 8 | echo "\n 🔥 Deploying in production 🔥\n" 9 | echo " 🔥 🔥 🔥 🔥 🔥 🔥 🔥 🔥 🔥 🔥\n\n" 10 | firebase use --add MyApp 11 | yarn run build 12 | # create a release tag and push it 13 | version=$(date '+cloudFunctions--%Y/%m/%d--%H.%M.%S') 14 | git tag -a $version -m "Cloud functions Production release version $version" 15 | echo "Release tagged $version" 16 | git push --tags 17 | 18 | else 19 | echo "Please enter prod to deploy in production" 20 | exit 1 # terminate and indicate error 21 | fi 22 | 23 | firebase deploy --only functions 24 | 25 | # send a notification when deploy is done 26 | terminal-notifier -group 'MyApp' -title 'MyApp CloudFunctions' -message 'Finished' || echo "done" 27 | say "Cloud functions deployed" -r 100 || echo "deployed" 28 | -------------------------------------------------------------------------------- /functions/src/index.ts: -------------------------------------------------------------------------------- 1 | import cors from "cors" 2 | import * as express from "express" 3 | import * as functions from "firebase-functions" 4 | import { reportError } from "../utils/stackdriverLogger" 5 | import trackingLogger from "./trackingLogger" 6 | 7 | require("firebase-functions/lib/logger/compat") // Prettify logs in firebase 8 | 9 | /************************************************* 10 | * 🤖 API 11 | ************************************************/ 12 | // 🔼 Express app can be called with an HTTP request https://firebase.google.com/docs/functions/http-events 13 | const app = express() 14 | const corsConfig = cors({ origin: true }) 15 | // 1️⃣ set request handler: 16 | app.use(corsConfig) 17 | // 2️⃣ set the app controllers: [EDIT HERE ✍🏻] 18 | app.use("/trackingLogger", trackingLogger) 19 | 20 | // health check 21 | app.get("/", (req, res) => res.status(200).json({ status: "ok" })) 22 | 23 | // 3️⃣ set error handler: 24 | app.use((err, req, res, next) => { 25 | console.error(err) 26 | reportError(err, { request: req, details: "API Error" }, req) 27 | res.status(500).json({ error: err.message || "An unknown error occurred." }) 28 | }) 29 | // 4️⃣ export: 30 | exports.api = functions.https.onRequest(app) 31 | -------------------------------------------------------------------------------- /addOn/src/props.ts: -------------------------------------------------------------------------------- 1 | // User properties & script properties are the equivalent of the local storage for Gscript add-ons: 2 | 3 | // USER PROPERTY: 4 | export const setUserProperty = (uid: string, property: any) => 5 | PropertiesService?.getUserProperties()?.setProperty(uid, property) 6 | export const getUserProperty = (uid: string) => PropertiesService?.getUserProperties()?.getProperty(uid) 7 | export const deleteUserProperty = (uid: string) => { 8 | console.log("⏩ deleting user prop") 9 | PropertiesService?.getUserProperties()?.deleteProperty(uid) 10 | } 11 | 12 | // USER PROPERTIES: 13 | export const setUserProperties = (properties: any) => PropertiesService?.getUserProperties()?.setProperties(properties) 14 | export const getUserProperties = () => PropertiesService?.getUserProperties()?.getProperties() 15 | export const deleteUserProperties = () => { 16 | console.log("⏩ deleting user props") 17 | return PropertiesService?.getUserProperties()?.deleteAllProperties() 18 | } 19 | 20 | // SCRIPT PROPERTIES: 21 | export const setScriptProperty = (properties: any) => 22 | PropertiesService?.getScriptProperties()?.setProperties(properties) 23 | export const deleteScriptProperties = () => { 24 | console.log("⏩ deleting script props") 25 | return PropertiesService?.getScriptProperties()?.deleteAllProperties() 26 | } 27 | -------------------------------------------------------------------------------- /addOn/src/db.ts: -------------------------------------------------------------------------------- 1 | import config from "./config" 2 | 3 | declare const FirestoreApp: any 4 | 5 | const { client_email, private_key, project_id } = config.firebaseSecret 6 | const firestore = FirestoreApp.getFirestore(client_email, private_key, project_id) 7 | const getUserEmail = (): any => Session.getEffectiveUser().getEmail() // or Session.getActiveUser() 8 | const timestamp = Date.now() 9 | 10 | export const fetchUserData = (): any => { 11 | let userDocument = null 12 | // 1️⃣ TRY TO FETCH USER 13 | try { 14 | userDocument = firestore.getDocument(`users/${getUserEmail()}`)?.obj 15 | } catch (error) { 16 | console.error(`>> No user found, creating one. \n>>Email: ${getUserEmail()} \n>>Error: ${error}`) 17 | } 18 | if (userDocument) return userDocument 19 | // 2️⃣ IF DOESNT EXIST => CREATE NEW USER IN DB 20 | const newUserObject = { 21 | userEmail: getUserEmail(), 22 | createdAt: timestamp 23 | } 24 | firestore.createDocument(`users/${getUserEmail()}`, newUserObject) 25 | Utilities.sleep(1000) 26 | // 3️⃣ NOW TRY AGAIN WITH NEW CREATED USER 27 | try { 28 | userDocument = firestore.getDocument(`users/${getUserEmail()}`)?.obj 29 | } catch (error) { 30 | console.warn(`>> Error with ${getUserEmail() || "unknown"}:`) 31 | console.error(error) 32 | } 33 | if (userDocument) return userDocument 34 | // 4️⃣ FAILSAFE 35 | return newUserObject 36 | } 37 | 38 | export const fetchTrackingData = (): any => { 39 | const trackingData = firestore.getDocuments(`users/${getUserEmail()}/trackingData`).map((doc) => { 40 | const { events } = doc.obj 41 | const trackingPath = doc.path.split("/") 42 | const trackingId = trackingPath[trackingPath.length - 1] 43 | return { events, trackingId } 44 | }) 45 | return trackingData || [] 46 | } 47 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "scripts": { 4 | "lint": "tslint --project tsconfig.json", 5 | "dev": "NODE_ENV='development' webpack --progress --colors --watch --display-error-details", 6 | "build": "NODE_ENV='production' webpack --progress --colors", 7 | "build_staging": "NODE_ENV='development' webpack --progress --colors", 8 | "serve": "firebase use --add gmailmerge && firebase serve --only functions", 9 | "shell": "firebase functions:shell", 10 | "start": "npm run shell", 11 | "deploy": "./deploy.sh", 12 | "logs": "firebase functions:log" 13 | }, 14 | "engines": { 15 | "node": "16" 16 | }, 17 | "main": "index.js", 18 | "dependencies": { 19 | "@google-cloud/error-reporting": "^1.1.3", 20 | "@google-cloud/logging-winston": "^3.0.6", 21 | "@google-cloud/storage": "^4.3.2", 22 | "algoliasearch": "^4.4.0", 23 | "atob": "^2.1.2", 24 | "axios": ">=0.21.1", 25 | "bluebird": "^3.7.2", 26 | "body-parser": "^1.19.0", 27 | "clean-webpack-plugin": "^3.0.0", 28 | "copy-webpack-plugin": "^5.1.1", 29 | "cors": "^2.8.5", 30 | "express": "^4.17.1", 31 | "firebase": "^7.15.4", 32 | "firebase-admin": "^9.1.1", 33 | "firebase-functions": "^3.11.0", 34 | "googleapis": "^46.0.0" 35 | }, 36 | "devDependencies": { 37 | "@firebase/testing": "^0.20.4", 38 | "@types/node": "^16.6.2", 39 | "config": "^3.0.1", 40 | "config-webpack": "^1.0.4", 41 | "firebase-functions-test": "^0.2.1", 42 | "ts-loader": "^6.0.2", 43 | "tslint": "^5.17.0", 44 | "tslint-config-airbnb": "^5.11.1", 45 | "tslint-config-prettier": "^1.18.0", 46 | "typescript": "^3.7.2", 47 | "webpack": "^4.33.0", 48 | "webpack-cli": "^3.3.10", 49 | "webpack-node-externals": "^1.7.2" 50 | }, 51 | "private": true 52 | } 53 | -------------------------------------------------------------------------------- /addOn/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "extends": [ 5 | "airbnb", 6 | "eslint:recommended", 7 | "prettier", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier/@typescript-eslint" 10 | ], 11 | "plugins": ["prettier", "@typescript-eslint", "jsx-a11y", "googleappsscript"], 12 | "settings": { 13 | "import/parsers": { 14 | "@typescript-eslint/parser": [".ts", ".tsx"] 15 | }, 16 | "import/resolver": { 17 | "typescript": {} 18 | } 19 | }, 20 | 21 | "rules": { 22 | "no-this-assignment": false, 23 | "import-name": false, 24 | "no-increment-decrement": false, 25 | "no-implicit-dependencies": false, 26 | "ordered-imports": false, 27 | "semi": "off", 28 | "no-use-before-define": "off", 29 | "comma-dangle": "off", 30 | "camelcase": "warn", 31 | "prefer-object-spread": "warn", 32 | "spaced-comment": "off", 33 | "no-else-return": "off", 34 | "no-console": "off", 35 | "prettier/prettier": "warn", 36 | "dot-notation": "off", 37 | "explicit-module-boundary-types": "off", 38 | "import/order": "off", 39 | "consistent-return": "off", 40 | "@typescript-eslint/ban-types": "off", 41 | "@typescript-eslint/no-var-requires": "off", 42 | "import/prefer-default-export": "off", 43 | "import/no-extraneous-dependencies": "warn", 44 | "import/extensions": [ 45 | "warn", 46 | "never", 47 | { 48 | "js": "never", 49 | "jsx": "never", 50 | "ts": "never", 51 | "tsx": "never" 52 | } 53 | ], 54 | "@typescript-eslint/indent": [2, 2], 55 | "@typescript-eslint/no-use-before-define": ["off"], 56 | "eslintjsx-a11y/click-events-have-key-events": "off" 57 | }, 58 | "env": { 59 | "googleappsscript/googleappsscript": true 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /functions/webpack.config.js: -------------------------------------------------------------------------------- 1 | const nodeExternals = require("webpack-node-externals") 2 | const path = require("path") 3 | 4 | const devMode = process.env.NODE_ENV === "development" 5 | const isProduction = process.env.NODE_ENV === "production" 6 | 7 | const SOURCE_DIR = path.resolve(__dirname, "src") 8 | const OUTPUT_DIR = path.resolve(__dirname, "dist") 9 | 10 | const ConfigWebpackPlugin = require("config-webpack") 11 | const CopyWebpackPlugin = require("copy-webpack-plugin") 12 | const { CleanWebpackPlugin } = require("clean-webpack-plugin") 13 | 14 | module.exports = { 15 | mode: devMode ? "development" : "production", 16 | entry: SOURCE_DIR, 17 | output: { 18 | path: OUTPUT_DIR, 19 | filename: "index.js", // <-- Important 20 | libraryTarget: "this" // <-- Important 21 | }, 22 | target: "node", // <-- Important 23 | resolve: { 24 | extensions: [".ts", ".tsx", ".json"] 25 | }, 26 | // Generate sourcemaps for proper error messages 27 | devtool: "source-map", 28 | // we exclude all node dependencies 29 | externals: [nodeExternals()], 30 | mode: isProduction ? "production" : "development", 31 | optimization: { 32 | // We no not want to minimize our code. 33 | minimize: false 34 | }, 35 | performance: { 36 | // Turn off size warnings for entry points 37 | hints: false 38 | }, 39 | module: { 40 | rules: [ 41 | { 42 | test: /\.tsx?$/, 43 | loader: "ts-loader", 44 | options: { 45 | transpileOnly: true, 46 | compilerOptions: { 47 | module: "es2015" 48 | } 49 | }, 50 | exclude: /node_modules/ 51 | } 52 | ] 53 | }, 54 | plugins: [ 55 | new CleanWebpackPlugin({ 56 | dry: !isProduction 57 | }), 58 | new ConfigWebpackPlugin(), 59 | new CopyWebpackPlugin([ 60 | { 61 | from: "./package.json", 62 | to: OUTPUT_DIR 63 | } 64 | ]) 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /functions/utils/stackDriverLogger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston' 2 | import * as Express from 'express' 3 | // Imports the Google Cloud client library 4 | import { ErrorReporting } from '@google-cloud/error-reporting' 5 | 6 | // Imports the Google Cloud client library for Winston 7 | import { LoggingWinston } from '@google-cloud/logging-winston' 8 | 9 | let errorReporting 10 | let loggingWinston 11 | let winstonLogger 12 | 13 | export const initStackDriverLogger = () => { 14 | // if (CONFIG.env !== "production") return 15 | try { 16 | // Instantiates a client 17 | errorReporting = new ErrorReporting({ 18 | reportMode: 'production' 19 | }) 20 | loggingWinston = new LoggingWinston() 21 | 22 | // Create a Winston logger that streams to Stackdriver Logging 23 | // Logs will be written to: "projects/YOUR_PROJECT_ID/logs/winston_log" 24 | winstonLogger = winston.createLogger({ 25 | level: 'info', 26 | transports: [ 27 | new winston.transports.Console(), 28 | // Add Stackdriver Logging 29 | loggingWinston 30 | ] 31 | }) 32 | } catch (error) { 33 | console.error(error) 34 | } 35 | } 36 | 37 | // report error 38 | export const reportError = ( 39 | error: Error, 40 | metadata?: Object, 41 | request?: Express.Request 42 | ) => { 43 | // if (CONFIG.env !== "production") return 44 | try { 45 | const event = errorReporting.event() 46 | event.setMessage(error.stack) 47 | if (request) { 48 | event.setUserAgent(request.get('User-Agent')) 49 | event.setHttpMethod(request.method) 50 | event.setUrl(request.originalUrl) 51 | } 52 | errorReporting.report(event) 53 | logError(error, metadata) 54 | } catch (error) { 55 | console.error(error) 56 | } 57 | } 58 | 59 | // log with full details 60 | export const logError = (error: Error, metadata?: Object) => { 61 | // if (CONFIG.env !== "production") return 62 | try { 63 | winstonLogger.error(error.message, metadata) 64 | } catch (error) { 65 | console.error(error) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /addOn/src/common.ts: -------------------------------------------------------------------------------- 1 | export const createCatCard = (text: string, isHomepage?: boolean) => { 2 | // Explicitly set the value of isHomepage as false if null or undefined. 3 | if (!isHomepage) isHomepage = false 4 | const now = new Date() 5 | // Replace formward slashes in the text, as they break the CataaS API. 6 | const caption = text.replace(/\//g, " ") 7 | const imageUrl = Utilities.formatString( 8 | "https://cataas.com/cat/says/%s?time=%s", 9 | // @ts-ignore 10 | encodeURIComponent(caption), 11 | now.getTime() 12 | ) 13 | const image = CardService.newImage().setImageUrl(imageUrl).setAltText("Meow") 14 | const action = CardService.newAction() 15 | .setFunctionName("onChangeCat") 16 | .setParameters({ text: text, isHomepage: isHomepage.toString() }) 17 | const button = CardService.newTextButton() 18 | .setText("Change cat t") 19 | .setOnClickAction(action) 20 | .setTextButtonStyle(CardService.TextButtonStyle.FILLED) 21 | const buttonSet = CardService.newButtonSet().addButton(button) 22 | const footer = CardService.newFixedFooter().setPrimaryButton( 23 | CardService.newTextButton() 24 | .setText("Powered by cataas.com") 25 | .setOpenLink(CardService.newOpenLink().setUrl("https://cataas.com")) 26 | ) 27 | const section = CardService.newCardSection().addWidget(image).addWidget(buttonSet) 28 | const card = CardService.newCardBuilder().addSection(section).setFixedFooter(footer) 29 | if (!isHomepage) { 30 | const peekHeader = CardService.newCardHeader() 31 | .setTitle("Contextual Cat") 32 | .setImageUrl("https://www.gstatic.com/images/icons/material/system/1x/pets_black_48dp.png") 33 | .setSubtitle(text) 34 | card.setPeekCardHeader(peekHeader) 35 | } 36 | return card.build() 37 | } 38 | 39 | export const onChangeCat = (e: { parameters: { text: any; isHomepage: string } }) => { 40 | console.log(e) 41 | const text = e.parameters.text 42 | const isHomepage = e.parameters.isHomepage === "true" 43 | const card = createCatCard(text, isHomepage) 44 | const navigation = CardService.newNavigation().updateCard(card) 45 | const actionResponse = CardService.newActionResponseBuilder().setNavigation(navigation) 46 | return actionResponse.build() 47 | } 48 | -------------------------------------------------------------------------------- /addOn/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "addOns": { 3 | "common": { 4 | "name": "TEST ENV", 5 | "logoUrl": "https://www.gstatic.com/images/icons/material/system/1x/pets_black_48dp.png", 6 | "useLocaleFromApp": true, 7 | "universalActions": [ 8 | { 9 | "label": "Learn more about Cataas", 10 | "openLink": "https://cataas.com" 11 | } 12 | ], 13 | "layoutProperties": { 14 | "primaryColor": "#2772ed" 15 | }, 16 | "homepageTrigger": { 17 | "runFunction": "onOpen" 18 | } 19 | }, 20 | "sheets": {}, 21 | "docs": {}, 22 | "slides": {}, 23 | "drive": {}, 24 | "calendar": {}, 25 | "gmail": { 26 | "contextualTriggers": [ 27 | { 28 | "unconditional": {}, 29 | "onTriggerFunction": "onGmailMessage" 30 | } 31 | ], 32 | "composeTrigger": { 33 | "selectActions": [ 34 | { 35 | "text": "Insert cat", 36 | "runFunction": "onGmailCompose" 37 | } 38 | ], 39 | "draftAccess": "METADATA" 40 | } 41 | } 42 | }, 43 | "timeZone": "America/New_York", 44 | "dependencies": { 45 | "libraries": [ 46 | { 47 | "userSymbol": "FirestoreApp", 48 | "libraryId": "1VUSl4b1r1eoNcRWotZM3e87ygkxvXltOgyDZhixqncz9lQ3MjfT1iKFw", 49 | "version": "33" 50 | } 51 | ] 52 | }, 53 | "exceptionLogging": "STACKDRIVER", 54 | "oauthScopes": [ 55 | "https://www.googleapis.com/auth/documents.currentonly", 56 | "https://www.googleapis.com/auth/spreadsheets.currentonly", 57 | "https://www.googleapis.com/auth/presentations.currentonly", 58 | "https://www.googleapis.com/auth/drive.addons.metadata.readonly", 59 | 60 | "https://www.googleapis.com/auth/gmail.modify", 61 | "https://www.googleapis.com/auth/gmail.addons.current.action.compose", 62 | "https://www.googleapis.com/auth/gmail.addons.current.message.readonly", 63 | "https://www.googleapis.com/auth/gmail.addons.execute", 64 | "https://www.googleapis.com/auth/gmail.addons.current.message.metadata", 65 | 66 | "https://www.googleapis.com/auth/script.locale", 67 | "https://www.googleapis.com/auth/script.external_request", 68 | "https://www.googleapis.com/auth/script.scriptapp", 69 | "https://www.googleapis.com/auth/userinfo.email" 70 | ], 71 | "runtimeVersion": "V8" 72 | } 73 | -------------------------------------------------------------------------------- /addOn/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Google-Workspace-Addon-Boilerplate", 3 | "version": "2.1.0", 4 | "description": "Google Apps Script Boilerplate", 5 | "scripts": { 6 | "login": "clasp login", 7 | "setup": "rm -rf .clasp.json && clasp create --type standalone --title 'My Google Workspace Addon' && clasp setting rootDir ./dist", 8 | "build": "webpack", 9 | "deploy": "rm -rf dist && npm run build && npx clasp push" 10 | }, 11 | "author": "nathgilson", 12 | "engines": { 13 | "node": ">=10.0.0", 14 | "npm": ">=6.0.0" 15 | }, 16 | "dependencies": { 17 | "axios": "^0.21.1", 18 | "eslint-plugin-import": "^2.22.1", 19 | "firestore-parser": "^0.9.0", 20 | "gas-client": "^0.1.0", 21 | "typescript": "^4.0.5", 22 | "use-interval": "^1.3.0" 23 | }, 24 | "devDependencies": { 25 | "@babel/cli": "^7.10.5", 26 | "@babel/core": "^7.11.1", 27 | "@babel/plugin-proposal-class-properties": "^7.10.4", 28 | "@babel/plugin-proposal-object-rest-spread": "^7.11.0", 29 | "@babel/plugin-transform-object-assign": "^7.10.4", 30 | "@babel/polyfill": "^7.10.4", 31 | "@babel/preset-env": "^7.11.0", 32 | "@google/clasp": "^2.3.0", 33 | "@types/node": "^14.14.21", 34 | "@typescript-eslint/eslint-plugin": "^4.8.1", 35 | "@typescript-eslint/parser": "^4.8.1", 36 | "@types/google-apps-script": "^1.0.41", 37 | "babel-eslint": "^10.1.0", 38 | "babel-loader": "^8.1.0", 39 | "babel-plugin-add-module-exports": "^1.0.2", 40 | "babel-plugin-transform-es3-member-expression-literals": "^6.22.0", 41 | "babel-plugin-transform-es3-property-literals": "^6.22.0", 42 | "config": "^3.3.1", 43 | "config-webpack": "^1.0.5", 44 | "copy-webpack-plugin": "^6.0.3", 45 | "dotenv": "^8.2.0", 46 | "dynamic-cdn-webpack-plugin": "^5.0.0", 47 | "eslint": "^7.13.0", 48 | "eslint-config-airbnb": "^18.2.1", 49 | "eslint-config-airbnb-base": "^14.2.0", 50 | "eslint-config-prettier": "^6.15.0", 51 | "eslint-config-standard": "^14.1.1", 52 | "eslint-import-resolver-typescript": "^2.3.0", 53 | "eslint-loader": "^3.0.4", 54 | "eslint-plugin-babel": "^5.3.1", 55 | "eslint-plugin-googleappsscript": "^1.0.3", 56 | "eslint-plugin-json": "^2.1.2", 57 | "eslint-plugin-jsx-a11y": "^6.4.1", 58 | "eslint-plugin-node": "^11.1.0", 59 | "eslint-plugin-prettier": "^3.1.4", 60 | "eslint-plugin-promise": "^4.2.1", 61 | "eslint-plugin-standard": "^4.0.1", 62 | "gas-lib": "^2.0.3", 63 | "gas-types-detailed": "^1.0.0", 64 | "gas-webpack-plugin": "^1.0.4", 65 | "google-apps-script-webpack-dev-server": "^1.0.0", 66 | "module-to-cdn": "^3.1.5", 67 | "prettier": "^2.1.2", 68 | "tern": "^0.24.3", 69 | "terser-webpack-plugin": "^2.3.7", 70 | "ts-loader": "^8.0.2", 71 | "webpack": "^4.44.1", 72 | "webpack-cli": "^3.3.12", 73 | "webpack-dev-server": "^3.11.0" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /functions/src/subscriptionWebhook.ts: -------------------------------------------------------------------------------- 1 | import express from "express" 2 | import { firestore } from "../utils/firebase" 3 | import stripe from "../utils/stripe" 4 | 5 | const router = express.Router() 6 | 7 | router.get("/", (req, res) => { 8 | // health check endpoint 9 | res.status(200).send({ data: "up and running" }) 10 | }) 11 | 12 | router.post("/", (req: any, res: any) => { 13 | let event 14 | try { 15 | // check stripe event signature 16 | event = stripe.webhooks.constructEvent(req.rawBody, req.headers["stripe-signature"], CONFIG.stripe.webhookSecret) 17 | } catch (error) { 18 | console.error(error) 19 | return res.status(400).send({ error }) 20 | } 21 | 22 | if (!event || !event.type || !event.data?.object) return res.status(400).send({ event, error: "Missing data" }) 23 | 24 | const { status, customer: customerId, id: subscriptionId } = event.data.object 25 | 26 | const { id: plan } = event.data.object?.plan 27 | 28 | return stripe.customers 29 | .retrieve(customerId) 30 | .then((customer: any) => { 31 | if (customer.deleted) { 32 | throw Error(`Customer was deleted`) 33 | } 34 | return customer.email 35 | }) 36 | .then((email) => { 37 | switch (event.type) { 38 | // CREATED 39 | case "customer.subscription.created": 40 | return firestore 41 | .collection("users") 42 | .doc(email) 43 | .set( 44 | { 45 | subscriptionId, 46 | status, 47 | plan 48 | }, 49 | { merge: true } 50 | ) 51 | .then(() => res.status(200).send({ event, status: "created" })) 52 | 53 | // UPDATED 54 | case "customer.subscription.updated": 55 | return firestore 56 | .collection("users") 57 | .doc(email) 58 | .set( 59 | { 60 | subscriptionId, 61 | status, 62 | plan 63 | }, 64 | { merge: true } 65 | ) 66 | .then(() => res.status(200).send({ event, status: "updated" })) 67 | 68 | // DELETED 69 | case "customer.subscription.deleted": 70 | return firestore 71 | .collection("users") 72 | .doc(email) 73 | .set( 74 | { 75 | subscriptionId, 76 | status, 77 | plan 78 | }, 79 | { merge: true } 80 | ) 81 | .then(() => res.status(200).send({ event, status: "deleted" })) 82 | 83 | default: 84 | return res.status(404).send({ event, error: "unknown event" }) 85 | } 86 | }) 87 | .catch((error) => { 88 | console.error(`>> WEBHOOK ERROR: ${error}`) 89 | return res.status(400).send({ event, error: error.message || "Customer not found" }) 90 | }) 91 | }) 92 | 93 | export default router 94 | -------------------------------------------------------------------------------- /addOn/src/gmail.ts: -------------------------------------------------------------------------------- 1 | import { createCatCard } from "./common" 2 | 3 | export const onGmailMessage = (e) => { 4 | console.log(e) 5 | // Get the ID of the message the user has open. 6 | const messageId = e.gmail.messageId 7 | // Get an access token scoped to the current message and use it for GmailApp 8 | // calls. 9 | const accessToken = e.gmail.accessToken 10 | GmailApp.setCurrentMessageAccessToken(accessToken) 11 | // Get the subject of the email. 12 | const message = GmailApp.getMessageById(messageId) 13 | let subject = message.getThread().getFirstMessageSubject() 14 | // Remove labels and prefixes. 15 | subject = subject.replace(/^([rR][eE]|[fF][wW][dD])\:\s*/, "").replace(/^\[.*?\]\s*/, "") 16 | return createCatCard(subject) 17 | } 18 | 19 | export const onGmailCompose = (e) => { 20 | console.log(e) 21 | const header = CardService.newCardHeader() 22 | .setTitle("Insert cat") 23 | .setSubtitle("Add a custom cat image to your email message.") 24 | // Create text input for entering the cat's message. 25 | const input = CardService.newTextInput() 26 | .setFieldName("text") 27 | .setTitle("Caption") 28 | .setHint("What do you want the cat to say?") 29 | // Create a button that inserts the cat image when pressed. 30 | const action = CardService.newAction().setFunctionName("onGmailInsertCat") 31 | const button = CardService.newTextButton() 32 | .setText("Insert cat") 33 | .setOnClickAction(action) 34 | .setTextButtonStyle(CardService.TextButtonStyle.FILLED) 35 | const buttonSet = CardService.newButtonSet().addButton(button) 36 | // Assemble the widgets and return the card. 37 | const section = CardService.newCardSection().addWidget(input).addWidget(buttonSet) 38 | const card = CardService.newCardBuilder().setHeader(header).addSection(section) 39 | return card.build() 40 | } 41 | 42 | export const onGmailInsertCat = (e) => { 43 | console.log(e) 44 | // Get the text that was entered by the user. 45 | const text = e.formInput.text 46 | // Use the "Cat as a service" API to get the cat image. Add a "time" URL 47 | // parameter to act as a cache buster. 48 | const now = new Date() 49 | let imageUrl = "https://cataas.com/cat" 50 | if (text) { 51 | // Replace formward slashes in the text, as they break the CataaS API. 52 | const caption = text.replace(/\//g, " ") 53 | imageUrl += Utilities.formatString( 54 | "/says/%s?time=%s", 55 | // @ts-ignore 56 | encodeURIComponent(caption), 57 | now.getTime() 58 | ) 59 | } 60 | const imageHtmlContent = '' 61 | const response = CardService.newUpdateDraftActionResponseBuilder() 62 | .setUpdateDraftBodyAction( 63 | CardService.newUpdateDraftBodyAction() 64 | .addUpdateContent(imageHtmlContent, CardService.ContentType.MUTABLE_HTML) 65 | .setUpdateType(CardService.UpdateDraftBodyType.IN_PLACE_INSERT) 66 | ) 67 | .build() 68 | return response 69 | } 70 | -------------------------------------------------------------------------------- /addOn/webpack.config.js: -------------------------------------------------------------------------------- 1 | /********************************* 2 | * import webpack plugins 3 | ********************************/ 4 | const path = require("path") 5 | const webpack = require("webpack") 6 | const CopyWebpackPlugin = require("copy-webpack-plugin") 7 | const GasPlugin = require("gas-webpack-plugin") 8 | const TerserPlugin = require("terser-webpack-plugin") 9 | const ConfigWebpackPlugin = require("config-webpack") 10 | 11 | /********************************* 12 | * set up environment variables 13 | ********************************/ 14 | const dotenv = require("dotenv").config() 15 | const parsed = dotenv.error ? {} : dotenv.parsed 16 | const envVars = parsed || {} 17 | 18 | /********************************* 19 | * define entrypoints 20 | ********************************/ 21 | // our destination directory 22 | const destination = path.resolve(__dirname, "dist") 23 | // define server paths 24 | const serverEntry = "./src/index.ts" 25 | // define appsscript.json file path 26 | const copyAppscriptEntry = "./appsscript.json" 27 | 28 | /********************************* 29 | * Declare settings 30 | ********************************/ 31 | // webpack settings for copying files to the destination folder 32 | const copyFilesConfig = { 33 | name: "COPY FILES - appsscript.json", 34 | mode: "production", // unnecessary for this config, but removes console warning 35 | entry: copyAppscriptEntry, 36 | output: { 37 | path: destination 38 | }, 39 | plugins: [ 40 | new CopyWebpackPlugin({ 41 | patterns: [ 42 | { 43 | from: copyAppscriptEntry, 44 | to: destination 45 | } 46 | ] 47 | }) 48 | ] 49 | } 50 | 51 | // webpack settings used by both client and server 52 | const sharedClientAndServerConfig = { 53 | context: __dirname 54 | } 55 | 56 | // webpack settings used by the server-side code 57 | const serverConfig = { 58 | ...sharedClientAndServerConfig, 59 | name: "SERVER", 60 | mode: "production", 61 | entry: serverEntry, 62 | output: { 63 | filename: "code.js", 64 | path: destination, 65 | libraryTarget: "this" 66 | }, 67 | resolve: { 68 | extensions: [".ts", ".js", ".json"] 69 | }, 70 | module: { 71 | rules: [ 72 | // typescript config 73 | { 74 | test: /\.ts?$/, 75 | exclude: /node_modules/, 76 | use: [ 77 | { 78 | loader: "babel-loader" 79 | }, 80 | { 81 | loader: "ts-loader" 82 | } 83 | ] 84 | }, 85 | { 86 | test: /\.js$/, 87 | exclude: /node_modules/, 88 | use: { 89 | loader: "babel-loader" 90 | } 91 | } 92 | ] 93 | }, 94 | optimization: { 95 | minimize: true, 96 | minimizer: [ 97 | new TerserPlugin({ 98 | sourceMap: true, 99 | terserOptions: { 100 | // ecma 5 is needed to support Rhino "DEPRECATED_ES5" runtime 101 | // can use ecma 6 if V8 runtime is used 102 | ecma: 5, 103 | warnings: false, 104 | parse: {}, 105 | compress: { 106 | properties: false 107 | }, 108 | mangle: false, 109 | module: false, 110 | output: { 111 | beautify: true 112 | } 113 | } 114 | }) 115 | ] 116 | }, 117 | plugins: [ 118 | new ConfigWebpackPlugin(), 119 | new webpack.DefinePlugin({ 120 | "process.env": JSON.stringify(envVars), 121 | "process.env.NODE_ENV": JSON.stringify("production") 122 | }), 123 | new GasPlugin() 124 | ] 125 | } 126 | 127 | module.exports = [ 128 | // 1. Copy appsscript.json to destination, 129 | // 2. Set up webpack dev server during development 130 | // Note: devServer settings are only read in the first element when module.exports is an array 131 | copyFilesConfig, 132 | // 3. Create the server bundle 133 | serverConfig 134 | ] 135 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

Your boilerplate project for
developing Google Workspace add-ons

6 | 7 |

8 | *** 9 |

10 | 11 | [Google Workspace add-ons](https://developers.google.com/workspace/add-ons/how-tos/building-gsuite-addons) are the extensions that can be found in the side panel of most Google apps (Gmail, Google Drive, Sheets, Docs, Slides, etc), unlike the more complexe [Editor add-ons](https://developers.google.com/workspace/add-ons/how-tos/building-editor-addons) that can be found in the "Add-ons" tab of Google Sheets, Docs and Slides. 12 | 13 |

14 | 15 |

16 | 17 | --- 18 | 19 | ## 📝 Table of Contents 20 | 21 | - [About](#about) 22 | - [Install](#install) 23 | - [Prerequisites](#prerequisites) 24 | - [Getting started](#getting-started) 25 | - [Deploy](#deploy) 26 | - [Usage](#usage) 27 | - [The included sample app](#the-included-sample-app) 28 | - [Typescript](#new-typescript) 29 | - [Adding packages](#adding-packages) 30 | - [Modifying scopes](#modifying-scopes) 31 | - [Autocomplete](#autocomplete) 32 | - [Card Builder](#card-builder) 33 | 34 |
35 | 36 | ## 🔎 About 37 | 38 | [Google Apps Script](https://developers.google.com/apps-script/overview) is Google's Javascript-based development platform for building applications and add-ons for Google Sheets, Docs, Forms and other Google Apps. 39 | 40 | This repo is a boilerplate project that uses the same development tools that you use for building traditional web apps (ES6, TypeScript, Node, Firebase, etc), all inside Google Apps Script projects. 41 | 42 | See below how to get started! 43 | 44 |
45 | 46 | ## 🚜 Install 47 | 48 | These instructions will get you set up with a copy of the project code on your local machine. It will also get you logged in to `clasp` so you can manage script projects from the command line. 49 | 50 | See [deploy](#deploy) for notes on how to deploy the project and see it live in Gmail, Calendar, and Drive. 51 | 52 | ### Prerequisites 53 | 54 | - Make sure you're running at least [Node.js](https://nodejs.org/en/download/) v10 and `npm` v6. 55 | 56 | - You'll need to enable the Google Apps Script API. You can do that by visiting [script.google.com/home/usersettings](https://script.google.com/home/usersettings). 57 | 58 | ### 🏁 Getting started 59 | 60 | **1.** First, let's clone the repo and install the dependencies. 61 | 62 | ```bash 63 | git clone https://github.com/nathgilson/Google-Workspace-Addon-Boilerplate 64 | cd Google-Workspace-Addon-Boilerplate 65 | cd addOn 66 | npm install 67 | ``` 68 | 69 |

70 | 71 |

72 | 73 | **2.** Next, we'll need to log in to [clasp](https://github.com/google/clasp), which lets us manage our Google Apps Script projects locally. 74 | 75 | ```bash 76 | npm run login 77 | ``` 78 | 79 |

80 | 81 |

82 | 83 | **3.** Now let's run the setup script to create a New spreadsheet and script project from the command line. 84 | 85 | ```bash 86 | npm run setup 87 | ``` 88 | 89 | Alternatively, you can use an existing Google Spreadsheet and Script file instead of creating a new one. 90 | 91 |
92 | See instructions here for using an existing project. 93 | 94 | You will need to update the `.clasp.json` file in the root of this project with the following three key/value pairs: 95 | 96 | ```json 97 | { 98 | "scriptId": "1PY037hPcy................................................", 99 | "parentId": ["1Df30......................................."], 100 | "rootDir": "./dist" 101 | } 102 | ``` 103 | 104 | - `scriptId`: Your existing script project's `scriptId`. You can find it by opening your spreadsheet, selecting **Tools > Script Editor** from the menubar, then **File > Project properties**, and it will be listed as "Script ID". 105 | 106 | - `parentId` (optional): An array with a single string, the ID of the parent file (spreadsheet, doc, etc.) that the script project is bound to. You can get this ID from the url, where the format is usually `https://docs.google.com/spreadsheets/d/{id}/edit`. This allows you to run `npm run open` and open your file directly from the command line. 107 | 108 | - `rootDir`: This should always be `"./dist"`, i.e. the local build folder that is used to store project files. 109 | 110 |
111 | 112 | Next, let's deploy the app so we can see it live in Google Workspace. 113 | 114 |
115 | 116 | ## 🚀 Deploy 117 | 118 | Run the deploy command. You may be prompted to update your manifest file. Type 'yes'. 119 | 120 | ```bash 121 | npm run deploy 122 | ``` 123 | 124 | The deploy command will build all necessary files using production settings, including all server code (Google Apps Script code) and config files. All bundled files will be outputted to the `dist/` folder, then pushed to the Google Apps Script project. 125 | 126 | Now open Google Sheets and navigate to your new spreadsheet (e.g. the file "My Project"). You can also run `npm run open`. Make sure to refresh the page if you already had it open. You will now see a new menu item appear containing your app! 127 | 128 |

129 | 130 |

131 | 132 |
133 | 134 | ## ⛏️ Usage 135 | 136 | ### The included sample app 137 | 138 | The sample app is inspired from Google's quickstart: [Cats Google Workspace Add-on](https://developers.google.com/workspace/add-ons/cats-quickstart) 139 | 140 | ### Typescript 141 | 142 | This project supports typescript. 143 | 144 | To use, simply use a typescript extension in the code (.ts), and your typescript file will compile to the proper format. 145 | 146 | To use typescript in server code, just change the file extension to .ts. The server-side code already utilizes type definitions for Google Apps Script APIs. 147 | 148 | A basic typescript configuration is used here, because after code is transpiled from typescript to javascript it is once again transpiled to code that is compatible with Google Apps Script. However, if you want more control over your setup you can modify the included [tsconfig.json file](./tsconfig.json). 149 | 150 | ### Adding packages 151 | 152 | For instance, install `axios` from npm: 153 | 154 | ```bash 155 | npm install axios 156 | ``` 157 | 158 | Important: Since Google Apps Scripts projects don't let you easily reference external files, this project will bundle an entire app into one .gs file. This can result in large files if you are importing large packages. To help split up the files, you can grab a CDN url for your package and declare it in the [webpack file, here](./webpack.config.js#L157). If set up properly, this will add a script tag that will load packages from a CDN, reducing your bundle size. 159 | 160 | ### Modifying scopes 161 | 162 | If you modify this project to work with Google Forms or Calendar, make sure to edit the oauthScopes in the [appscript.json file](./appsscript.json). 163 | 164 | See https://developers.google.com/apps-script/manifest for information on the `appsscript.json` structure. 165 | 166 | ### Autocomplete 167 | 168 | This project includes support for autocompletion and complete type definitions for Google Apps Script methods. 169 | 170 |

171 | 172 |

173 | 174 | All available methods from the Google Apps Script API are shown with full definitions and links to the official documentation, plus information on argument, return type and sample code. 175 | 176 | ### Card builder 177 | 178 | The online [Card builder](https://gw-card-builder.web.app/) can help you prototype your app's interface. 179 | 180 |
181 | --------------------------------------------------------------------------------