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 withvue-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
- Using Bootstrap CSS for Form: Text Input and Button.
- Using VeeValidate for Form Email Validation.
- Using
vue-toasted
to display success/failure message. - Using FontAwesome Icon for loading animation during network request.
- Using Cloud Functions for Firebase client SDK to call cloud functions.
<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
- Make sure cloud functions is compatible/callable with Cloud Functions for Firebase client SDK
- Use WTForms for RESTful API validation.
- Use Firestore as database storage.
- Use SendGrid to send template email upon signup.
- Create another cloud functions to handle unsubscribe.
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
- Deploy VuePress with Firebase Hosting
- Configure Firebase Hosting Rewrites Request for Cloud Functions