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

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
  • Use WTForms for validation.
  • Use Firestore as database storage.
  • Use SendGrid to send template email upon signup.
  • Create another cloud functions to handle unsubscribe.
  • 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-validateyarn 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-coreyarn add @fortawesome/free-solid-svg-iconsyarn 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/callableimport 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, validatorsclass 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_mappingdef 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 = NonePROJECT_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 base64class 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 jsonifyfrom werkzeug.datastructures import ImmutableMultiDictfrom firebase_admin import firestorefrom app.models import RequestInvitefrom app.forms import RequestInviteForm# jwtimport timeimport jwt# sendgridfrom sendgrid import SendGridAPIClientfrom sendgrid.helpers.mail import Mail, Emailfrom python_http_client.exceptions import HTTPErrorAPP_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('[email protected]', APP_NAME),        # subject=f"Welcome to {APP_NAME}",        # html_content=html_content        )    message.add_bcc("[email protected]")    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 jwtfrom flask import render_templatefrom firebase_admin import firestoreUNSUBSCRIBE_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

❤️ Is this article helpful?

Buy me a coffee ☕ or support my work via PayPal to keep this space 🖖 and ad-free.

Do send some 💖 to @d_luaz or share this article.

✨ By Desmond Lua

A dream boy who enjoys making apps, travelling and making youtube videos. Follow me on @d_luaz

👶 Apps I built

Travelopy - discover travel places in Malaysia, Singapore, Taiwan, Japan.