Julie Ng

Julie Ng

← Work

Antwort Signature

In May 2014, I set a goal to build a small app from prototype to launch in 6-8 weeks. I occasionally help friends and clients with E-Mail signatures. Why not automate this? To make this fun, I wanted to dynamically generate forms based on set template designs.

Live Preview instead of WYSIWYG

The problem with WYSIWYG in E-Mail is that E-Mail HTML is finicky. To be bulletproof across all major clients including finicky Outlook and Gmail on Android, the code has to be sealed tight. So the net best option is a live preview of the signature.

Antwort E-Mail Signature Generator
Antwort E-Mail Signature Generator

Stack

  • Ruby on Rails - powers the backend of the website
  • RSpec - especially to test, that permissions work and certain actions are only available to teachers, etc.
  • Underscore.js - for useful functional helpers.
  • Backbone.js - as my JavaScript framework, giving me models, collections and views to work with.
  • Handlebars.js - for templates used in the Backbone app.
  • Jasmine - for testing the Backbone app

To learn more about the Antwort Signature app, read the "I shipped something" blog article from September 2014, which goes in depth into the building process, what I learned, what I forgot and why I shipped months instead of weeks later.

Modelling a Design

Installing E-Mail Signatures is usually just copy and paste. I don't want users to do that while customizing their signature either, so I needed two templates per design. So for each design, we have:

  • a Preview HTML template using standard <div>s for the editor
  • an Email HTML template to be generated with <table>s for Email
  • a JSON file that defines all the editable fields, which is used to generate a form

in total 3 templates per design. Here are the templates for the Offsides Signature.

Design template as JSON
        
{
  "title": "Offsides",
  "id": "offsides",
  "author": "Julie Ng",
  "version": "1.3",
  "price" : {
    "USD" : "34.99",
    "EUR" : "29.99"
  },
  "fieldsets": [
    {
      "category": "Colors",
      "fields": [
        {
          "label": "Company",
          "type": "color",
          "css-property": "color",
          "placeholder": "#444444"
        },
        {
          "label": "Link",
          "type": "color",
          "css-property": "color",
          "placeholder": "#7aa6b6"
        },
        {
          "label": "Label",
          "type": "color",
          "css-property": "color",
          "placeholder": "#444444"
        },
        {
          "label": "Text",
          "type": "color",
          "css-property": "color",
          "placeholder": "#444444"
        },
        {
          "label": "Footer",
          "type": "color",
          "css-property": "color",
          "placeholder": "#aaaaaa"
        },
        {
          "label": "Border",
          "type": "color",
          "css-property": "border-color",
          "placeholder": "#cccccc"
        }
      ]
    },
    {
      "category": "Fonts and Borders",
      "fields": [
        {
          "label": "Font Family",
          "type": "select",
          "options": [
            "Helvetica, sans-serif",
            "Arial, sans-serif",
            "Verdana, sans-serif",
            "Georgia, serif",
            "Times New Roman, Times, serif",
            "Courier New, Courier, monospace"
          ],
          "css-property": "font-family",
          "placeholder": "Helvetica, sans-serif"
        },
        {
          "label": "Border Style",
          "type": "select",
          "options": {
            "solid": "Solid",
            "dashed": "Dashed",
            "dotted": "Dotted"
          },
          "css-property": "border-style",
          "placeholder": "dashed"
        }
      ]
    },
    {
      "category": "Personal",
      "fields": [
        {
          "label": "Name",
          "type": "string",
          "placeholder": "Jane Smith"
        },
        {
          "label": "Job title",
          "type": "string",
          "placeholder": "President"
        }
      ]
    },
    {
      "category": "Address",
      "fields": [
        {
          "label": "Company",
          "type": "string",
          "placeholder": "ABC Inc."
        },
        {
          "label": "Logo",
          "type": "image",
          "hint": "URL should start with https, to prevent browser security warnings.",
          "placeholder": {
            "source": "https://antwort-signatures.s3.amazonaws.com/images/antwort-logo.png",
            "width": "180",
            "height": "30"
          }
        },
        {
          "label": "Street Address",
          "type": "textarea",
          "placeholder": "321 Broadway<br>New York, NY 10108"
        }
      ]
    },
    {
      "category": "Contact",
      "fields": [
        {
          "label": "Email",
          "type": "contact",
          "placeholder": {
            "title": "E-Mail",
            "channel": "email",
            "display": "[email protected]",
            "value": "[email protected]",
            "visible": "true"
          }
        },
        {
          "label": "Phone",
          "type": "contact",
          "placeholder": {
            "title": "Phone",
            "channel": "phone",
            "display": "(555) 123-4567",
            "value": "+15551234567",
            "visible": "true"
          }
        },
        {
          "label": "Fax",
          "type": "contact",
          "placeholder": {
            "title": "Fax",
            "channel": "fax",
            "display": "(555) 123-4569",
            "value": "+15551234569",
            "visible": "false"
          }
        },
        {
          "label": "Web",
          "type": "contact",
          "placeholder": {
            "title": "Web",
            "channel": "web",
            "display": "www.company.com",
            "value": "http://www.company.com",
            "visible": "true"
          }
        }
      ]
    },
    {
      "category": "Footer",
      "fields": [
        {
          "label": "Footer",
          "type": "textarea",
          "placeholder": "This email and any files transmitted with it are confidential and intended solely for the use of the individual or entity to whom they are addressed."
        }
      ]
    }
  ]
}
        
      
Design Preview HTML
        
<div class="body js-has-border-style-select js-has-border-color js-has-font-family-select">

  <div class="intro">
    <div class="name js-has-text-color" id="js-preview-name">{{name}}</div>
    <div class="title js-has-text-color" id="js-preview-job-title">{{job-title}}</div>
  </div>

  <div class="image logo-wrapper" id="js-logo-wrapper" title="Drag image edge to resize image"><img src="{{logo.source}}" style="border: 0; vertical-align: middle;" width="{{logo.width}}" height="{{logo.height}}" border="0" hspace="0" vspace="0" id="js-preview-logo" alt="{{company}}"></div>

  <div class="company js-has-company-color" id="js-preview-company">{{company}}</div>
  <div class="address js-has-text-color" id="js-preview-street-address">
    {{{ textarea street-address }}}
  </div>

  <dl class="contact">
    {{#each contact}}
    <dt id="js-preview-{{ hook }}-title" class="js-has-label-color"{{#unless visible}} style="display:none;"{{/unless}}>{{ title }}</dt>
    <dd id="js-preview-{{ hook }}"{{#unless visible}} style="display:none;"{{/unless}}>{{linkTo this}}</dd>
    {{/each}}
  </dl>
</div>
<div class="footer js-has-footer-color js-has-font-family-select" id="js-preview-footer">
  {{{ textarea footer }}}
</div>

        
      

Modelling Orders with VAT

As I wrote in my "I shipped something" blog article, implementing payment was what threw me way off schedule. This type of project will teaches appreciation for specs and good code design. This is the current version of the order model, which handles:

  • Application of VAT to order
  • Discounts via coupons
  • Price changes based on currency toggle
Order Model
        
/**
 * Order Model
 *
 * @constructor
 * @class Order
 * @namespace App.Models
 * @extends Backbone.Model
 */
App.Models.Order = Backbone.Model.extend({

  /**
   * URL to save order
   *
   * @property saveURL {String}
   */
  saveURL: '/email-signature/generator/save',

  /**
   * Default Attributes
   *
   * @property defaults {Object}
   */
  defaults: {
    currency: 'USD',
    coupon: '',
    discount: 0,
    savings: 0,
    taxRate: 0, // vat rate
    item: 0,    // item base price before any discounts
    subtotal: 0,
    tax: 0,
    total: 0,
    stripeToken: '',
    ui: {
      isEUR: false,
      isUSD: true,
      isTaxable: false,
      title: ''
    }
  },

  /**
   * Initializer
   *
   * @method initialize
   */
  initialize: function(opts){
    this.customer  = opts.customer;
    this.signature = opts.signature;

    // Customer Listeners
    this.customer.on('change:country', this._onCountryChange, this);
    this.customer.on('change:vatId', this._onVatIdChange, this);

    // Order Listeners
    this.on('change', this._onAnyChange, this);
    this.on('change:currency', this._onCurrencyChange, this);

    // set initial price (after we've added listeners)
    this.set('item', this._getPriceByCurrency(this.get('currency')));
  },

  // ----------------
  //  Event Handlers
  // ----------------

  /**
   * To do on _any_ model change
   *
   * @method _onAnyChange
   */
  _onAnyChange: function(){
    this._updateUIVars();
    this._calculateTotals();
  },

  /**
   * Handler for currency change (usu. via select menu)
   * 1) updates item price
   *
   * @method _onCurrencyChange
   * @param model {Model}, actually just self
   * @param cur {String}, USD or EUR
   * @param options {Object}
   */
  _onCurrencyChange: function(model, cur, options){
    this.set('item', this._getPriceByCurrency(cur));
  },

  /**
   * Handler for when a customer's country changes
   * 1) get tax rate
   * 2) force EUR as currency if necessary
   *
   * @method _onCountryChange
   * @param model {Model}, actually just self
   * @param country {String}, e.g. US or GB
   * @param options {Object}
   */
  _onCountryChange: function(m, country, opts){
    var rate = App.VatRates.getByCountry(country),
        attrs = {taxRate: rate};
    if (rate) {
      _.extend(attrs, {currency: 'EUR'});
    }
    this.set(attrs);
  },

  /**
   * Handler for when a customer's VAT Id changes
   * 1) updates tax rate
   *
   * @method _onVatIdChange
   * @param model {Model}, actually just self
   * @param id {String}, valid VAT Id
   * @param options {Object}
   */
  _onVatIdChange: function(m, id, opts) {
    if (id === '') {
      this.set('taxRate', App.VatRates.getByCountry(this.customer.get('country')));
    } else {
      this.set('taxRate', 0);
    }
  },


  // ----------------
  //  Send to Server
  // ----------------

  /**
   * Places Order, final form submit after we have stripe token
   *
   * @method save
   */
  save: function(){

    // clone attributes to send
    var token = this.get('csrfToken'),
        data  = _.clone(this.attributes),
        design = _.clone(this.get('signature').get('design').attributes);

    // replace Models with regular JavaScript Objects
    data.customer  = this.get('customer').attributes;
    data.signature = this.get('signature').templateData();
    data.design    = _.pick(design, 'id', 'title', 'version');

    $.ajax({
      type: 'POST',
      url: this.saveURL,
      data: data,
      beforeSend: function(xhr){
        xhr.setRequestHeader('X-CSRF-Token', token);
      },
      error:   _.bind(this._onSaveError, this),
      success: _.bind(this._onSaveSucces, this)
    });
  },

  /**
   * Success handler
   *
   * @method _onSaveSucces
   * @param data {Object|String} whatever returned from server. Can be JSON, HTML, etc.
   * @param status {String} e.g. "Success"
   * @param jqXHR {jqXHR}
   */
  _onSaveSucces: function(data, status, jqXHR){
    this.trigger('server:success', data.url);
  },

  /**
   * Error handler
   *
   * @method onServerError
   * @param jqXHR {jqXHR}
   * @param status {String} e.g. "error"
   * @param error  {String} message, e.g. "Internal Server Error"
   */
  _onSaveError: function(jqXHR, status, error){
    this.trigger('server:error', error);
  },


  // ----------------
  //  Calculations
  // ----------------

  /**
   * Calculates Totals based on current subtotal and tax.
   * Then goes through and updates subtotal, tax and total in one go.
   *
   * @method _calculateTotals
   */
  _calculateTotals: function(){
    var savings = this._calculateSavings(),
        sub     = this._calculateSubtotal(savings),
        tax     = this._calculateTax(sub),
        total   = this._round(sub + tax);

    this.set({
      savings:  savings,
      subtotal: sub,
      tax:      tax,
      total:    total
    });
  },

  /**
   * Calculate Subtotal
   * First resets subtotal to item price
   *
   * @method _calculateSubtotal
   * @param savings {Float}
   * @return subtotal {Float}
   */
  _calculateSubtotal: function(savings){
    savings = savings || 0;
    var s = this.get('item') - savings;
    return this._round(s);
  },

  /**
   * Sets savings based on discount
   * used for internal calculations and DOM display
   *
   * @method _calculateSavings
   * @return savings {Float}
   */
  _calculateSavings: function(){
    var savings = 0;
    if (this.get('discount')){
      savings = this.get('item') * this.get('discount');
    }
    return this._round(savings);
  },

  /**
   * Calculates and sets tax amount based on VAT rate and param subtotal
   * Takes param because we won't have set our subtotal yet at time of calculation
   *
   * @method _calculateTax
   * @param subtotal {Float}
   * @return tax {Float}
   */
  _calculateTax: function(subtotal){
    var tax = this.get('taxRate') * subtotal;
    return this._round(tax);
  },


  // -------------
  //  Helpers, UI
  // -------------

  /**
   * Set logic variables required for Handlebars template
   *
   * @method _updateUIVars
   */
  _updateUIVars: function(){
    var canTax   = (this.get('taxRate') > 0), // zero if not vat country
        currency = this.get('currency'),
        isUSD    = (currency == 'USD'),
        isEUR    = !isUSD;
    this.set({
      ui: {
        isTaxable: canTax,
        isEUR: isEUR,
        isUSD: isUSD,
        title: this.signature.get('title'),
        hasSavings: (this.get('savings') > 0)
      }
    });
  },

  /**
   * Helper to get price from our saved signature by currency
   *
   * @method _getPriceByCurrency
   * @param cur {String} currency, USD or EUR
   * @return {Float}
   */
  _getPriceByCurrency: function(cur){
    return parseFloat(this.signature.get('price')[cur]);
  },

  /**
   * Rounds a float to one-hundredth of decimal for pricing
   *
   * @method _round
   * @param num {Float}
   * @return {Float}
   */
  _round: function(num){
    return (Math.round(num*100)/100);
  }
});
        
      
Order Specs
        
describe "Order Model", ->
  o = {}

  beforeEach ->
    c = new App.Models.Customer()
    d = new App.Models.Design({price: {USD: 0, EUR: 0}})
    s = new App.Models.Signature({design: d})
    o = new App.Models.Order({customer: c, signature: s})

  describe "Initialization", ->
    it "has default attributes", ->
      expect(o.get('currency')).toBe 'USD'
      expect(o.get('discount')).toBe 0
      expect(o.get('savings')).toBe 0
      expect(o.get('taxRate')).toBe 0
      expect(o.get('item')).toBe 0
      expect(o.get('subtotal')).toBe 0
      expect(o.get('tax')).toBe 0
      expect(o.get('total')).toBe 0

  describe "Coupons", ->
    it "can calculate savings", ->
      o.set({
        item: 10
        discount: 0.35
      })
      expect(o.get('savings')).toBe 3.5

      o.set('discount', 1)
      expect(o.get('savings')).toBe 10
      expect(o.get('total')).toBe 0

    it "does not charge tax if order is free", ->
      o.set({
        item: 8.40,
        taxRate: 0.2,
        discount: 1.0
      })
      expect(o.get('tax')).toBe 0
      expect(o.get('total')).toBe 0

    it "calculates tax after savings", ->
      o.set({
        item: 10,
        taxRate: 0.2,
        discount: 0.5
      })
      expect(o.get('tax')).toBe 1
      expect(o.get('total')).toBe 6

    it "keeps discount when switching item price / currency", ->
      o.set({
        item: 10,
        taxRate: 0.2,
        discount: 0.5
      })
      expect(o.get('total')).toBe 6

      o.set({
        item: 15
      })
      expect(o.get('subtotal')).toBe 7.5
      expect(o.get('total')).toBe 9


  describe "Calculations", ->

    it "tax", ->
      o.set('item', 10)
      o.set('taxRate', 0.2)
      expect(o.get('tax')).toBe 2

      o.set('item', 8.40)
      o.set('taxRate', 0.19)
      expect(o.get('tax')).toBe 1.60

    it "cannot set subtotal directly", ->
      o.set('item', 5)
      o.set('subtotal', 10)
      expect(o.get('subtotal')).toBe 5

    it "resets subtotal when setting item", ->
      o.set('item', 5)
      expect(o.get('subtotal')).toBe 5
      o.set('item', 10)
      expect(o.get('subtotal')).toBe 10

    it "resets totals when switching item price", ->
      o.set({
        item: 10,
        taxRate: 0.2
      })
      expect(o.get('tax')).toBe 2
      expect(o.get('total')).toBe 12

      o.set({item: 5})
      expect(o.get('tax')).toBe 1
      expect(o.get('total')).toBe 6

    describe "totals", ->
      it "without tax", ->
        o.set({
          item: 1,
          taxRate: 0
        })
        expect(o.get('total')).toBe 1

        o.set('item', 9.5)
        expect(o.get('total')).toBe 9.5

      it "with tax", ->
        o.set({
          item: 9.5,
          taxRate: 0.25
        })
        expect(o.get('total')).toBe 11.88

      describe "with discounts", ->
        it "without tax", ->
          o.set({
            item: 9.5,
            taxRate: 0,
            discount: 0.5
          })
          expect(o.get('total')).toBe 4.75
        it "with tax", ->
          o.set({
            item: 8.4,
            taxRate: 0.19,
            discount: 0.5
          })
          expect(o.get('total')).toBe 5.00
        
      

Code Style

In general, I prefer normal ECMAScript 5 syntax. As much as I love Ruby, I never got into CoffeeScript, in part because I found debugging difficult. The exception is for specs, where readability is more important to me than being consistent with syntax style.

Lastly, I generally use the YUIDoc to document my JavaScript.

Site Design

Of course, as someone with design experience, the app should not just work well, it should also look well ;-). Here are some examples:

← Case Studies