Pre-launch Signup Email Component With VuePress/Vue.js and Cloud Functions

June 1, 2019

I shall create a landing page to allow user to request invite before product launch.

  • Create landing page with VuePress.
  • Write a VuePress component to collect email for invite request.
    • Form design using Bootstrap
    • Use FontAwesome for loading animation
    • Perform validation with VeeValidate and show message with vue-toasted
    • Use Firebase client SDK - javascript library to call clound functions
  • Create a Python Cloud Functions
  • Firebase Hosting Deployment

NOTE: The requirement might seems simple, but the entire process involve many components.

Landing Page

Refer to Setup Landing Page Website With VuePress 1.x.

VuePress Component

Create a VuePress component with email text input and a button to request invite.

Setup Firebase Javascript Client

Using Cloud Functions for Firebase client SDK to call cloud functions instead of using ajax library like Axios.

yarn add firebase

Setup vee-validate and vue-toasted

Utilize vee-validate for Form Validation and vue-toasted to display success/failure message.

yarn add vee-validate
yarn add vue-toasted

Setup Bootstrap

Utilize Bootstrap for Form CSS.

yarn add bootstrap

Setup FontAwesome

Utilize FontAwesome Icon for loading animation.

yarn add @fortawesome/fontawesome-svg-core
yarn add @fortawesome/free-solid-svg-icons
yarn add @fortawesome/vue-fontawesome

Import & Initialize Components

Edit docs/.vuepress/enhanceApp.js.

import 'bootstrap/dist/css/bootstrap.css';
import { library } from '@fortawesome/fontawesome-svg-core';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import VeeValidate from 'vee-validate';

export default ({
  Vue, // the version of Vue being used in the VuePress app
  options, // the options for the root Vue instance
  router, // the router instance for the app
  siteData // site metadata
}) => {

  library.add(faSpinner);
  Vue.component('font-awesome-icon', FontAwesomeIcon);

  Vue.use(VeeValidate);

  // prevent ReferenceError: window is not defined during production build
  import('vue-toasted').then(module => {
    Vue.use(module.default, {
      duration: 6000
    });
  }).catch(error => {

  });
}

RequestInvite.vue

Go to the VuePress project, edit /docs/.vuepress/components/RequestInvite.vue

<template>
  <div>
    <form @submit.prevent="onSubmit" class="needs-validation" novalidate>
      <div class="form-row  justify-content-md-center">
        <div class="my-3">Join our early access program and be the first to experience the warmth of 暖心芽.</div>
      </div>
      <div class="form-row justify-content-md-center">
        <div class="col-md-auto mb-3">
          <label for="inputEmail" class="sr-only">Your email</label>
          <input v-model="email" v-validate="{ required: true, email: true }" :class="{'form-control': true, 'is-invalid': errors.has('email') && state.isValidation , 'is-valid': !errors.has('email') && state.isValidation }" name="email" type="email" id="inputEmail" placeholder="Your email">
          <div class="invalid-feedback">{{ errors.first('email') }} </div>
        </div>
        <div class="col-md-auto mb-3">
          <button type="submit" class="btn btn-primary" style="position: relative;" :disabled="state.isSending">Request Invite <font-awesome-icon :icon="['fas', 'spinner']" pulse size="lg" style="margin: auto; position: absolute; top: 0;  bottom: 0; left: 0; right: 0; color: #210B61;" v-show="state.isSending" /></button>
        </div>
      </div>
    </form>
  </div>
</template>

<script>
// https://firebase.google.com/docs/functions/callable
import firebase from 'firebase/app';
import 'firebase/functions';
const firebaseConfig = {
  apiKey: "AIza...",
  authDomain: "PROJECT_NAME.firebaseapp.com",
  databaseURL: "https://PROJECT_NAME.firebaseio.com",
  projectId: "PROJECT_NAME",
  storageBucket: "PROJECT_NAME.appspot.com",
  messagingSenderId: "240...",
  appId: "1:240..."
};
const app = firebase.initializeApp(firebaseConfig);
const functions = firebase.functions();

export default {
  data() {
    return {
      email: '',
      state: {
        isValidation: false,
        isSending: false
      }
    }
  },
  methods: {
    onSubmit() {
      this.save();
    },
    save() {
      this.state.isValidation = true;
      this.$validator.validate().then(valid => {
        if (!valid) {
          console.log('not valid');
        }
        else {
          this.state.isSending = true
          functions.useFunctionsEmulator('/functions');
          const testMessage = functions.httpsCallable('request_invite');
          testMessage({email: this.email}).then((result) => {
            this.state.isSending = false
            this.email = ''
            this.$toasted.success('Successful 😊');
          }).catch((error) => {
            this.state.isSending = false
            console.log(error);
            this.$toasted.error('Ops, something went wrong 😢');
          });
        }
      });
    }
  }
}
</script>

NOTE: firebaseConfig is from Firebase Console -> Project Overview -> + Add app -> Web.

Cloud Functions

Setup Python cloud functions

Packages

Edit requirements.txt.

# https://pypi.org/project/firebase-admin/
firebase-admin==2.16.0
# https://pypi.org/project/WTForms/
WTForms==2.2.1
# https://pypi.org/project/sendgrid/
sendgrid==6.0.5
# https://pypi.org/project/PyJWT/
PyJWT==1.7.1

WTForms

from wtforms import Form, BooleanField, StringField, validators

class RequestInviteForm(Form):
    email = StringField('Email', [validators.Length(min=6, max=120), validators.Email()])
    # email = StringField('Email', [validators.Email()])

Json Abort

from flask import jsonify, abort

# https://cloud.google.com/apis/design/errors#http_mapping
def json_abort(status_code, message, details=None):
    data = {
        'error': {
            'code': status_code,
            'message': message
        }
    }
    if details:
        data['error']['details'] = details
    response = jsonify(data)
    response.status_code = status_code
    abort(response)

Firestore

firestore_db = None

PROJECT_ID = "..."

def init_firestore():
    import firebase_admin
    from firebase_admin import credentials
    from firebase_admin import firestore

    global firestore_db
    if firestore_db:
        return firestore_db

    cred = credentials.ApplicationDefault()
    # cred = credentials.Certificate('keys/firebase-adminsdk.json')
    default_app = firebase_admin.initialize_app(cred, {
      'projectId': PROJECT_ID,
    })

    firestore_db = firestore.client()
    return firestore_db
import base64

class RequestInvite:
    COLLECTION_NAME = 'request_invite'

    CREATED = 'created'
    MODIFIED = 'modified'
    EMAIL = 'email'
    USER_ID = 'user_id'
    LANG = 'lang'
    TIMEZONE = 'timezone'

    IS_ACTIVE = 'is_active'

    @staticmethod
    def create_id(value):
        return base64.urlsafe_b64encode(value.casefold().encode('utf-8')).decode('utf-8')

NOTE: Refer Create Document Id (Contraints) for Firestore (Python).

Request Invite Cloud Functions

from flask import jsonify
from werkzeug.datastructures import ImmutableMultiDict
from firebase_admin import firestore
from app.models import RequestInvite
from app.forms import RequestInviteForm

# jwt
import time
import jwt

# sendgrid
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail, Email
from python_http_client.exceptions import HTTPError

APP_NAME = "..."
UNSUBSCRIBE_REQUEST_INVITE_SECRET = "..."
SENDGRID_API_KEY = "..."

db = init_firestore()


def request_invite(request):
    if not request.is_json:
        json_abort(400, 'JSON request expected')

    form = RequestInviteForm(ImmutableMultiDict(request.json.get('data')))
    if not form.validate():
        json_abort(400, 'validation error', details=form.errors)

    email = form.email.data

    # TODO: check for spam
    doc_id = RequestInvite.create_id(email)
    doc_ref = db.collection(RequestInvite.COLLECTION_NAME).document(doc_id)

    doc_ref.set({
        RequestInvite.CREATED: firestore.SERVER_TIMESTAMP,
        RequestInvite.EMAIL: email,
        RequestInvite.LANG: request.json.get('lang'),
        RequestInvite.USER_ID: decoded_token['uid'] if decoded_token else None,
        RequestInvite.TIMEZONE: request.json.get('timezone'),
        RequestInvite.IS_ACTIVE: True
    })

    sg = SendGridAPIClient(SENDGRID_API_KEY)

    message = Mail(
        to_emails=email,
        from_email=Email('nuanxinya@luasoftware.com', APP_NAME),
        # subject=f"Welcome to {APP_NAME}",
        # html_content=html_content
        )
    message.add_bcc("desmond.lua@luasoftware.com")
    message.template_id = 'd-f36...'
    message.dynamic_template_data = {
        'APP_NAME': APP_NAME,
        'unsubsribe_token': jwt.encode({'email': email, 'time': int(time.time())}, UNSUBSCRIBE_REQUEST_INVITE_SECRET, algorithm='HS256').decode('utf-8')
    }

    try:
        response = sg.send(message)
        log.info(f"email.status_code={response.status_code}")
    except HTTPError as e:
        print(e)
        log.error(e)

    return jsonify({
        'data': {
            'id': doc_ref.id,
            'email': email
        }
    })

NOTE: Refer to SendGrid Templates with Parameters (Dynamic Template Data) and Send Email using Python Libraries.

Unsubscribe Cloud Functions

import jwt
from flask import render_template
from firebase_admin import firestore

UNSUBSCRIBE_REQUEST_INVITE_SECRET = "..."

db = init_firestore()

def unsubscribe_request_invite(request):
    token = request.args.get('token')
    if not token:
        abort(400, 'Missing token')

    try:
        data = jwt.decode(token, UNSUBSCRIBE_REQUEST_INVITE_SECRET, algorithms=['HS256'])
    except jwt.exceptions.DecodeError:
        abort(400, 'Invalid token')

    if 'email' not in data:
        abort(400, 'Missing data')

    email = data['email']
    doc_id = RequestInvite.create_id(email)
    doc_ref = db.collection(RequestInvite.COLLECTION_NAME).document(doc_id)
    doc = doc_ref.get()
    if not doc.exists:
        abort(400, 'Document not found')

    doc_ref.update({
        RequestInvite.MODIFIED: firestore.SERVER_TIMESTAMP,
        RequestInvite.IS_ACTIVE: False
        })

    return render_template('unsubscribe_request_invite.html', email=email)

templates/unsubscribe_request_invite.html

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Unsubscribe</title>
</head>
<body>
    <h4>Unsubscribe Successful</h4>
    <p>{{ email }}</p>
</body>
</html>

NOTE: Refer Secure Email Unsubscribe Link Query String (Python JWT).

Deployment

This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.