Create a gist now

Instantly share code, notes, and snippets.

What would you like to do?

Here I'm sharing a couple of quick notes on how I implemented an onboarding tour with mock data in my SaaS app WiseCash, which essentially reduced my customer support time to zero. This is a follow-up of this tweet.

Demo: if you sign-up here (no credit card required), you will see that you can start a quick tour, with fake data, not affecting your actual user data.

How this works

The "tour" button redirects to a regular page but with a specific "tour=1" parameter (https://www.wisecashhq.com/insights/burn-chart?tour=1 when logged in)

The top-level controller (ApplicationController) has a way to determine if we are in tour mode or not, for both regular pages or AJAX calls:

  def tour_mode?
    (params[:tour] == '1') || (request.headers['X-Tour-Mode'] == '1')
  end

This method is used in both controllers and views to figure out if the regular behaviour should occur, or if some "tour" behaviour must be achieved instead.

Later the app relies on that to decide to use real user data or a TourDataProvider (see code below) fake data (always up to date & relative to "today", which is important to compute dynamically since WiseCash charts are aiming at forecasting the bank account balance):

source = tour_mode? ? TourDataProvider : current_user

The TourDataProvider is a standalone class building non persisted objects dynamically, adjusted for each step of the tour (because different charts require different data to actually show something interesting).

All "destructive" actions are forbidden in tour mode, with things such as:

<% unless tour_mode? %>
<a class="hoveredit" href="#" data-bind="click: editAccount">edit</a>
<% end %>

But also in the controllers:

  before_action :forbid_in_tour_mode, except: :index

private
  def forbid_in_tour_mode
    return false if tour_mode?
  end

On the front-end side, the app instructs the Javascript that we are in tour mode & setup XHR accordingly, then start hopscotch which start manually on each page:

    // save this around so we can selectively disable features
    window.tourMode = <%= tour_mode?.to_json %>;
    <% if tour_mode? %>
    Tour.init(<%= Integer(params[:step] || 0) %>);
    <% end %>
class @Tour
  @init: (step = 0) =>
    # pass the tour flag for all ajax requests on our domain
    $.ajaxPrefilter (options, originalOptions, xhr) =>
      if options.type != 'GET'
        alert("Data is read-only during this tour. Come back later!")
        xhr.abort()
      if !options.crossDomain
        xhr.setRequestHeader('X-Tour-Mode', '1')
        xhr.setRequestHeader('X-Tour-DataSet', @tourDataSetFor(step))
        
    $(document).ready () =>
      hopscotch.startTour(Tour.tour(), step)

That's the gist of it. Feel free to ask questions on the gist, happy to give more insights.

module TourDataProvider
extend self
def bank_account_balance
17930
end
def fiscal_year_start_date
6.months.ago.at_beginning_of_month
end
def yearly_income_goal
120_000
end
def entries(tour_data_set)
case tour_data_set
when 'burn-chart' then data_set_burn_chart
when 'dashboard' then data_set_dashboard
when 'yearly-income' then data_yearly_income
else fail "Unsupported tour data set #{tour_data_set}"
end
end
def accounts_with_tags(tour_data_set)
entries(tour_data_set).map(&:account).uniq.map do |account|
{ name: account }
end
end
def new_entry(kind, due_date, amount, account, description, status = 'to_be_paid')
entry = Entry.new
entry.due_date = due_date
entry.kind = kind
entry.amount = amount
entry.account = account
entry.description = description
entry.status = status
entry
end
def data_yearly_income
start = fiscal_year_start_date
[
[3.weeks, 4250, 'Acme Corp', 'Project X, 1st iteration'],
[5.weeks, 3200, 'Acme Corp', 'Project X, 2nd iteration'],
[11.weeks, 2000, 'Super Corp', 'Retainer Agreement'],
[15.weeks, 5370, 'Acme Corp', 'Project X, 3rd iteration'],
[17.weeks, 7000, 'Super Corp'],
[21.weeks, 4000, 'Super Corp'],
[24.weeks, 3650, 'Acme Corp'],
[28.weeks, 3200, 'Acme Corp'],
[32.weeks, 1000, 'Super Corp'],
[36.weeks, 7300, 'Super Corp'],
[40.weeks, 1720, 'Acme Corp']
].map.with_index do |x, i|
status = i > 7 ? 'to_be_paid' : 'paid'
new_entry('income', start + x[0], (x[1] * yearly_income_goal) / 50_000, x[2], x[3] || '', status)
end
end
def data_set_dashboard
data = data_set_burn_chart
# add one overdue
data << new_entry('expense', Date.today - 1.day, 2750, 'Accountant', 'Yearly fees')
data
end
def data_set_burn_chart
today = Date.today
e = []
# a bit of recurring income, 3 months of work
e << new_entry('income', today + 20.days, 5700, 'Acme Corp', 'Project X, 1st iteration')
e << new_entry('income', today + 1.month + 20.days, 5700, 'Acme Corp', 'Project X, 2nd iteration')
e << new_entry('income', today + 2.month + 20.days, 11700, 'Acme Corp', 'Project X, 3rd iteration')
# quarterly taxes
e << new_entry('expense', today + 3.months, 3500, 'Taxes', 'Quarterly taxes')
e << new_entry('expense', today + 6.months, 3500, 'Taxes', 'Quarterly taxes')
e << new_entry('expense', today + 9.months, 3500, 'Taxes', 'Quarterly taxes')
e << new_entry('expense', today + 12.months, 3500, 'Taxes', 'Quarterly taxes')
# monthly stuff
12.times do |i|
e << new_entry('expense', today + 10.days + i.months, 2500, 'Salary John', '')
e << new_entry('expense', today + 10.days + i.months, 2500, 'Salary Sarah', '')
e << new_entry('expense', today + 25.days + i.months, 350, 'Amazon EC2', '')
e << new_entry('expense', today + 25.days + i.months, 700, 'PixelArt LTD', 'Outsourced web-design work')
# retainer agreement
e << new_entry('income', today + 5.days + i.months, 1200, 'SuperCorp', 'Retainer agreement')
end
e
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment