Using Stripe for SAAS payments

Letting users pay for your software service is important part of building a “Software as a Service” business. Accepting payment requires a third-party service, such as Stripe. Their PHP library makes it easy to accept credit cards and subscribe users to monthly payment plans. My examples use version 6.43. The Stripe JavaScript library is used to create secure UI elements that collect sensitive card data.

Before any coding, log into your Stripe account. Create a product with a monthly price. That product’s API ID is used to programmatically charge users and subscribe them recurring billing.

stripe product dashboard

User Interface

My software product gives users a 7-day free trial before core functionality is disabled. When they decide to activate their account they are presented with a credit card input user interface.

activate account subscription

It is built with basic HTML and CSS.

<style type="text/css">
	#card-element{
		width: 100%;
		margin-bottom: 10px; 
	}
	.StripeElement {
	  box-sizing: border-box;

	  height: 40px;

	  padding: 10px 12px;

	  border: 1px solid transparent;
	  border-radius: 4px;
	  background-color: white;

	  box-shadow: 0 1px 3px 0 #e6ebf1;
	  -webkit-transition: box-shadow 150ms ease;
	  transition: box-shadow 150ms ease;
	}

	.StripeElement--focus {
	  box-shadow: 0 1px 3px 0 #cfd7df;
	}

	.StripeElement--invalid {
	  border-color: #fa755a;
	}

	.StripeElement--webkit-autofill {
	  background-color: #fefde5 !important;
	}
</style>

<div id="stripe-payment-modal" class="modal stripe-payment-modal" style="display: none;">

	<!-- Modal content -->
	<div class="modal-content">
		<p>
		  <button type="button" class="dismiss-modal close" >&times;</button>
		</p>
		<p>Activate your account subscription.</p>
		<p><?php echo $price_point; ?> per month.</p>
		<form id="payment-form">
		  <div class="form-row">
		    <!-- <label for="card-element">
		      Credit or debit card
		    </label> -->
		    <div id="card-element">
		      <!-- A Stripe Element will be inserted here. -->
		    </div>

		    <!-- Used to display Element errors. -->
		    <div id="card-errors" role="alert"></div>
		  </div>

		  <button type="button" class="btn submit-payment">Submit Payment</button>
		</form>

  	</div>

</div>

The actual input elements are generated by Stripe’s JS. The Stripe form handles real-time validation and generates a secure token to be sent to your server.

<script src="https://js.stripe.com/v3/"></script>
<script type="text/javascript">

	$(document).ready(function() {
		// var stripe = Stripe('pk_test_xxxx'); //sandbox
		var stripe = Stripe('pk_live_xxxx');

		var elements = stripe.elements();

		// Custom styling can be passed to options when creating an Element.
		var style = {
		  base: {
		    color: '#32325d',
		    fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
		    fontSmoothing: 'antialiased',
		    fontSize: '16px',
		    '::placeholder': {
		      color: '#aab7c4'
		    }
		  },
		  invalid: {
		    color: '#fa755a',
		    iconColor: '#fa755a'
		  }
		};

		// Create an instance of the card Element.
		var card = elements.create('card', {style: style});

		// Add an instance of the card Element into the `card-element` <div>.
		card.mount('#card-element');

		// Handle real-time validation errors from the card Element.
		card.addEventListener('change', function(event) {
		  var displayError = document.getElementById('card-errors');
		  if (event.error) {
		    displayError.textContent = event.error.message;
		  } else {
		    displayError.textContent = '';
		  }
		});

		// Handle form submission.
		var form = document.getElementById('payment-form');
		form.addEventListener('submit', function(event) {
		  event.preventDefault();

		  stripe.createToken(card).then(function(result) {
		    if (result.error) {
		      // Inform the user if there was an error.
		      var errorElement = document.getElementById('card-errors');
		      errorElement.textContent = result.error.message;
		    } else {
		      // Send the token to your server.
		      stripeTokenHandler(result.token);
		    }
		  });
		});

		// Submit the form with the token ID.
		function stripeTokenHandler(token) {
		  // Insert the token ID into the form so it gets submitted to the server
		  var form = document.getElementById('payment-form');
		  var hiddenInput = document.createElement('input');
		  hiddenInput.setAttribute('type', 'hidden');
		  hiddenInput.setAttribute('name', 'stripeToken');
		  hiddenInput.setAttribute('value', token.id);
		  form.appendChild(hiddenInput);
		 
		  var data = $("#payment-form").serialize();
		  $.ajax({
		  	url:"/service-layer/stripe-service?method=subscribe",
		  	method: "POST",
		  	data: data,
		  	complete: function(response){
		  		console.log(response);
		  		window.location.reload();
		  	}
		  })
		}

		$(".submit-payment").click(function(){
			stripe.createToken(card).then(function(result) {
		    if (result.error) {
		    	// Inform the customer that there was an error.
		    	var errorElement = document.getElementById('card-errors');
		    	errorElement.textContent = result.error.message;
		    } else {
				$(".submit-payment").attr("disabled", "disabled").html('Working <i class="fas fa-spinner fa-spin"></i>');
		      	// Send the token to your server.
		      	stripeTokenHandler(result.token);
		    }
		  });
		});

	});

</script>

After referencing the CDN JS library, the Stripe object accepts a public API key. That object then creates a customizable element that can be mounted into an existing <div> on your webpage. In your JavaScript, you can either listen for the form to be submitted or for an arbitrary button to be clicked. Then, we rely on the Stripe object to create a card token, which we can pass along to our back-end service.

Creating a subscription

Once the token is passed along to the server, it can be used to subscribe to the monthly product. We will need to load the PHP library and provide our secret API key. The key can be found in Stripe’s web dashboard.

require_once('/stripe-php-6.43.0/init.php');
\Stripe\Stripe::setApiKey('sk_live_XXXXXXX');

A Stripe customer ID is needed to create the subscription. Our code checks if the user record already has a Stripe customer ID saved to our database (in case they signed up previously, and cancelled).  If not, we call the “customer create” method first.

function subscribe(){
	$stripe_token = $_POST['stripeToken'];
	$conn = $this->connection;
	
	if(isset($_SESSION['email'])){
		$email = $_SESSION['email'];
	}else{
		die("No email found.");
	}
	
	if(strlen($email)>0){
		$sql = "SELECT * FROM `account` WHERE email = ?"; 
		$result = $conn->prepare($sql); 
		$result->execute(array($email));
		$row = $result->fetch(PDO::FETCH_ASSOC);
	}
	$customer_id = $row['billing_customer_id'];
	//check if this account already has a billing_customer_id
	if(strlen($customer_id) < 1){
		//if not, create the customer
		$customer = \Stripe\Customer::create([
		  'email' => $email,
		  'source' => $stripe_token,
		]);
		$customer_id = $customer['id'];
		//write stripe ID to db
		$sql = "UPDATE `account` SET billing_customer_id = ? WHERE email = ?"; 
		$result = $conn->prepare($sql); 
		$result->execute(array($customer_id, $email));
	}

	// Create the subscription
	$subscription = \Stripe\Subscription::create([
	  'customer' => $customer_id,
	  'items' => [
	    [
	      // 'plan' => 'plan_FjOzMSMahyM7Ap', //sandbox.
	      'plan' => 'price_1He7vwLjg3FTECK8lb3GDQhV', //"basic" plan. setup in Stripe dashboard.
	    ],
	  ],
	  'expand' => ['latest_invoice.payment_intent'],
	  'billing_cycle_anchor' => time()
	]);
	$subscription_status = $subscription['status'];
	$subscription_id = $subscription['id'];
	if($subscription_status == "active"){
		//set current_period_end to 32 days (1 month plus some leeway) in the future. set past_due as false 
		$sql = "UPDATE `account` SET stripe_subscription_id = ?, current_period_end = ?, past_due = 0 WHERE email = ?"; 
		$result = $conn->prepare($sql);
		$past_due = false;
		$current_period_end = new DateTime;  
		$current_period_end->modify( '+32 day' );
		$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 
		$result->execute(array($subscription_id, $current_period_end, $email));
	}
}

With the subscription complete, their account’s “past due” property is marked as false and “current period end” is recorded to about 1 month in the future. The Stripe subscription ID is recorded for later use and reference.

Subscription life-cycle workflow

The application knows if an account is paying for premium service based on that “past due” property. After a user first signs up, that value is managed by a nightly scheduled cron job. If the “current period end” date is in the past, “past due” is marked as true, all projects are turned off, and a notification email is sent.

function checkPastDue(){	
	$sql = "SELECT * FROM `account` WHERE past_due = '0'";
	$result = $conn->prepare($sql); 
	$result->execute(); 
	$rows = $result->fetchAll(PDO::FETCH_ASSOC);
	$number_of_rows = $result->rowCount();
	
	include 'send-email-service.php';	

	foreach ($rows as $key => $value) {
		$current_period_end = $value['current_period_end'];
		$date = new DateTime($current_period_end);
		$now = new DateTime();
		if($date < $now) {
		   
		    //extend their trial 1 time, for an additional week
		    $extended_trial = $value['extended_trial'];
		    $accountid = $value['accountid'];
		    $email = $value['email'];
		    $billing_customer_id = $value['billing_customer_id'];
		    if($extended_trial == 0 && strlen($billing_customer_id) == 0){

		    	$sql = "UPDATE `account` SET extended_trial = '1' WHERE accountid = ?";
				$result1 = $conn->prepare($sql); 
				$result1->execute(array($accountid)); 

				$current_period_end = new DateTime;  
				$current_period_end->modify( '+8 day' );
				$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 

		    	$sql = "UPDATE `account` SET current_period_end = ? WHERE accountid = ?";
				$result1 = $conn->prepare($sql); 
				$result1->execute(array($current_period_end, $accountid)); 
				 
				$SendEmailService = new SendEmailService();
				
				$subject = "SplitWit trial extended!";

				$body = "Your SplitWit trial was supposed to expire today. As a courtesy, we're extending it another 7 days!<br><br>";
				
				$altBody = "Your SplitWit trial was supposed to expire today. We're extending it another 7 days!";

				$SendEmailService -> sendEmail($subject, $body, $altBody, $email);


		    }else{
				
				$sql = "UPDATE `account` SET past_due = '1' WHERE accountid = ?";
				$result1 = $conn->prepare($sql); 
				$result1->execute(array($accountid)); 
				
				//turn off all experiments
				$status = "Not running";
				$sql = "UPDATE `experiment` set status = ? where accountid = ?";
				$result2 = $conn->prepare($sql); 
				$result2->execute(array($status, $accountid));


				//update all snippets for this account (1 snippet per project)
				$sql = "SELECT * FROM `project` WHERE accountid = ?";
				$result3 = $conn->prepare($sql); 
				$result3->execute(array($accountid));
				$rows3 = $result3->fetchAll(PDO::FETCH_ASSOC);
				foreach ($rows3 as $key3 => $value3) {
					$projectid = $value3['projectid'];
			    	$write_snippet_service = new ProjectService();
					$write_snippet_service -> writeSnippetFile(false, false, $projectid);
				}
				
				$SendEmailService = new SendEmailService();
				$subject = "SplitWit account past due";

				$body = "Your SplitWit account is past due. Please login to your account and update your payment information to continue running A/B experiments.<br><br>";
				
				$body .= "A/B testing helps you increase conversion rates and avoid unnecessary risk. <a href='https://www.splitwit.com/blog/'>Check out the SplitWit blog for experiment ideas</a>. Remember, everything is testable!";
				 
				$body .= "<br><br><a href='https://www.splitwit.com/'><img src='https://www.splitwit.com/img/splitwit-logo.png'></a>";

				$altBody = "Your SplitWit account is past due. Please login to your account and update your payment information to continue running A/B experiments. A/B testing helps you increase conversion rates and avoid unnecessary risk. Check out the SplitWit blog for experiment ideas: https://www.splitwit.com/blog/ ";

				$SendEmailService -> sendEmail($subject, $body, $altBody, $email);

		    }
			
		}
	}

}

The “current period end” date is updated each month after the customer is invoiced.

webhook payment success

When the Stripe “payment succeeded” event happens, a webhook triggers our custom end-point code.

function webhookPaymentSuccess(){
	$payload = @file_get_contents("php://input"); 
	$endpoint_secret = "whsec_XXXX";

	$sig_header = $_SERVER["HTTP_STRIPE_SIGNATURE"];
	$event = null;

	try {
	  $event = \Stripe\Webhook::constructEvent(
	    $payload, $sig_header, $endpoint_secret
	  );
	} catch(\UnexpectedValueException $e) {
	  // Invalid payload
	  http_response_code(400); // PHP 5.4 or greater
	  exit();
	} catch(\Stripe\Error\SignatureVerification $e) {
	  // Invalid signature
	  http_response_code(400); // PHP 5.4 or greater
	  exit();
	}
	
	if($event->type == 'invoice.payment_succeeded'){

		$invoice = $event->data->object;
		$customer_id = $invoice['customer'];
		//update their accocunt current_period_end
		$conn = $this->connection;
		$sql = "UPDATE `account` SET  current_period_end = ?, past_due = 0 WHERE billing_customer_id = ?"; 
		$result = $conn->prepare($sql);
		$past_due = false;
		$current_period_end = new DateTime;  
		$current_period_end->modify( '+32 day' );
		$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 
		$result->execute(array($current_period_end, $customer_id));
	}else{
		http_response_code(400);
	    exit();
	}
	
	http_response_code(200);
	// var_dump($payload);
}

Although there is a webhook available for payment failure, the scheduled cron job handles that scenario.

If a user decides to cancel their subscription, we use their Stripe subscription ID and update their account records.

function cancelSubscription(){
	include '/var/www/html/service-layer/project-service.php';
	$conn = $this->connection;
	if(isset($_SESSION['userid'])){
		$accountid = $_SESSION['userid'];
	}else{
		die("No userid found.");
	}
	
	if(strlen($accountid)>0){
		
		$sql = "SELECT * FROM `account` WHERE accountid = ?"; 
		$result = $conn->prepare($sql); 
		$result->execute(array($accountid));
		$row = $result->fetch(PDO::FETCH_ASSOC);
	}
	$stripe_subscription_id = $row['stripe_subscription_id'];
	$subscription = \Stripe\Subscription::retrieve($stripe_subscription_id);
	$subscription->cancel();
	
	//#TODO: We should let the cron job handle this, so the user gets the rest of their month's service.
	//turn off experiments and update snippets. clear stripe IDs. set current_period_end to yesterday. set past_due = 1
	$current_period_end   = new DateTime;  
	$current_period_end->modify( '-1 day' );
	$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 
	$sql = "UPDATE `account` SET billing_customer_id = '', stripe_subscription_id = '', past_due = 1, current_period_end = ? WHERE accountid = ?"; 
	$result = $conn->prepare($sql); 
	$result->execute(array($current_period_end, $accountid));

	//turn off all experiments
	$status = "Not running";
	$sql = "UPDATE `experiment` set status = ? where accountid = ?";
	$result2 = $conn->prepare($sql); 
	$result2->execute(array($status, $accountid));

	//update all snippets for this account (1 snippet per project)
	$sql = "SELECT * FROM `project` WHERE accountid = ?";
	$result3 = $conn->prepare($sql); 
	$result3->execute(array($accountid));
	$rows3 = $result3->fetchAll(PDO::FETCH_ASSOC);
	foreach ($rows3 as $key3 => $value3) {
		$projectid = $value3['projectid'];
    	$write_snippet_service = new ProjectService();
		$write_snippet_service -> writeSnippetFile(false, false, $projectid);
	}

	$this->status = "complete";
}

Being able to charge money for your web based software is an important step in building a SAAS business. Using a Stripe as your payment infrastructure makes it easy. Build stuff that people love and you can get paid to do it!

 

Visual Website Editor in PHP

visual editor in php

Tools for non-programmers to manage websites are growing in demand. No-code solutions increase bandwidth. Many SaaS products promise marketers and project managers a way to get things done without a developer. A visual website editor baked into an app is an important technology for digital product builders. The ability for a software service to interact with a client’s website can be a critical selling point.

SplitWit A/B Testing is an example of a web app that had that requirement. It lets user specify an existing website and displays it in an editor window. The user can then click on elements, make changes, and add new content. Those changes are actually applied to the live website by a JavaScript snippet. This post explains how it was built using PHP and jQuery.

Showing the website via iFrame

Displaying an already existing website on your app is easy with an iFrame. But, since users should be able to view and edit any page they choose, challenges arise. If a non-SSL URL is used (http:// instead of https://), the editor app will throw a “mixed-content warning”. If the page has X-FRAME-OPTIONS set to DENY, then the request will fail. To get around these issues I load an internal page into the iFrame that contains all of the DOM code from the one specified.

var pageIframe = $('<iframe id="page-iframe" style="" src="/page-to-edit.php?baseUrl='+baseUrl+'&url=<?php echo $url; ?>"></iframe>').appendTo(".page-iframe-wrapper");

The page to be edited is entered into an input field.  That 3rd party webpage’s URL is passed along to my internal “page-to-edit.php” as a query parameter. From there, I get its full code using PHP Simple HTML DOM Parser. You’ll notice that I also pass along a “base url” – which is the root domain of the page. I’m able to grab it using JavaScript.

<?php
$url = "";
if(isset($_GET['url']) && strlen($_GET['url']) > 0){
  $url = $_GET['url'];
}
?>
function removeSubdirectoryFromUrlString(url, ssl){
  
  var ssl = ssl || false;
  if(url.indexOf("https://")){
    ssl = true;
  }

  url = url.replace("http://", "");
  url = url.replace("https://", "");
  var pathArray = url.split("/")
  url = pathArray[0];
  if(ssl){
    url = "https://" + url;
  }else{
    url = "http://" + url;
  }

  return url;
}

var url = "<?php echo $url; ?>";
var ssl = false;
var pageIframe;
 
if(url.length > 0){

  if(url.indexOf("https:") !== -1){
    ssl = true
  }
   
  var baseUrl = removeSubdirectoryFromUrlString(url, ssl);
  if(baseUrl.slice(-1) !== "/"){
    baseUrl = baseUrl + "/";
  }
}

I need to append that to any assets (images, scripts, etc.) that use relative references – or else they won’t load.

<?php 
// Report all errors
error_reporting(E_ALL);
ini_set("display_errors", 1);
require 'simple_html_dom.php';


class HtmlDomParser {
	
	/**
	 * @return \simplehtmldom_1_5\simple_html_dom
	 */
	static public function file_get_html() {
		return call_user_func_array ( '\simplehtmldom_1_5\file_get_html' , func_get_args() );
	}

	/**
	 * get html dom from string
	 * @return \simplehtmldom_1_5\simple_html_dom
	 */
	static public function str_get_html() {
		return call_user_func_array ( '\simplehtmldom_1_5\str_get_html' , func_get_args() );
	}
}

$base_url = $_GET['baseUrl'];
$url = $_GET['url'];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, TRUE);
$html = curl_exec($ch);
$redirectedUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
$url = $redirectedUrl;

$parse = parse_url($url);

$base_url = $parse["scheme"] . "://" . $parse["host"];

$html = @HtmlDomParser::file_get_html($url);

if(substr($base_url, 0, 1) == "/"){
    $base_url = substr_replace($base_url ,"",-1);
}

if($html === FALSE) { 
    echo "Sorry, we do not have permission to analyze that website. ";
    return;
}
foreach($html->find('script') as $element){
    $src = $element->src;
    // echo "<script>console.log('starting src: ".$src."')</script>";
    
    if (strlen($src) > 0 && strpos($src, '//') === false){
        if(substr($src, 0, 1) !== "/"){
            $src = "/" . $src;
        }
        $element->src = $base_url . $src;
    }
    if(strlen($element->integrity) > 0){
        $element->integrity = "";
    }
    // echo "<script>console.log('final src: ".$base_url . $src."')</script>";

    // echo $element->src . "\n";
} 
foreach($html->find('link') as $element){
    $src = $element->href;
    
    if (strlen($src) > 0 && strpos($src, '//') === false){
        if(substr($src, 0, 1) !== "/"){
            $src = "/" . $src;
        }
        $element->href = $base_url . $src;
    }
    if(strlen($element->integrity) > 0){
        $element->integrity = "";
    }
   
}
foreach($html->find('a') as $element){
    $src = $element->href;
    
    if (strlen($src) > 0 && strpos($src, '//') === false){
        if(substr($src, 0, 1) !== "/"){
            $src = "/" . $src;
        }
        $element->href = $base_url . $src;
    } 
  
}
foreach($html->find('img') as $element){
    $src = $element->src;
    if (strlen($src) > 0 && strpos($src, '//') === false){
        if(substr($src, 0, 1) !== "/"){
            $src = "/" . $src;
        }

        $element->src = $base_url . $src;
    } 
   
}
foreach($html->find('source') as $element){
    $src = $element->srcset;
    $sources = explode(",",$src);
    $src = trim($sources[0]);

    if (strlen($src) > 0 && strpos($src, '//') === false){
        if(substr($src, 0, 1) !== "/"){
            $src = "/" . $src;
        }

        $element->srcset = $base_url . $src;
    } 
}

echo $html;

?>

I check against five different element types for assets that could need to be updated: <script>, <link>, <a>, <img>, and <source>. In version 1.0, I had missed the <link> and <source> elements.

Now that I was able to load any website into my editor UI, I had to think about layout. I split the screen in half, having the right side display the website, and the left side showing editor options.

visual website editor

Element selection

Page-clicks in the editor window need to be intercepted to stop links from being followed and any other actions from happening. Instead, any thing clicked is selected by the editor and its details shown in the options panel.

highlight element selected

As elements are moused over, a semi-transparent red highlight is applied as a visual cue. It is achieved by injecting CSS into the iFrame and adding mouseenter and mouseout event listeners.

pageIframe.on('load', function(){
    var style = "<style>.highlighted{background-color:rgba(255, 0, 0, 0.5);} </style>";         
    pageIframe.contents().find("body").prepend(style);
     
    pageIframe.contents().find("body *").mouseenter(function(){
	$(this).addClass('highlighted'); 
    }).mouseout(function(){
	$(this).removeClass('highlighted');   
    });

});

When something is clicked, normal functionality is stopped by using the web browser’s preventDefault() method. Once the element is grabbed (using the this keyword), we want to be sure that we’re as deep into the DOM tree as possible. That way, our “Text / HTML” content won’t contain unnecessary code tags.

To do so, we use a while loop to iterate an arbitrary number of times. Eight iterations seems to be the number that gets us to the heart of any selected content, without taking too much time.

First, we check if the element has any child nodes – if it does, we grab the first one. If that node happens to be a <style> or <script> tag, we move onto the second instead. As long as that new node is not a formatting element (<strong>, <em>, etc.), we set it as the new element before continuing our loop.

testSelectorEl = $(this);
var i = 8;
while(i > 0){
	if ( $(testSelectorEl).children().length > 0 ) {
	  nextEl = $(testSelectorEl).find(">:first-child");
	  if(nextEl.is( "style" ) || nextEl.is( "script" ) || nextEl.is( "noscript" )){
	     nextEl = $(testSelectorEl).find(">:nth-child(2)");
	  }
	  if ( !nextEl.is( "u" ) && !nextEl.is( "i" ) && !nextEl.is( "strong" ) && !nextEl.is( "em" )) {
	    testSelectorEl = nextEl;
	  }
	}
	i--;
}

The above code is added to the click event listener on all elements within the iFrame. Next, below that code, we determine a unique CSS selector by which we can later reference the element. This is important for writing any changes to the JavaScript snippet to effect changes on the 3rd-party website.

var node = testSelectorEl;
var path = "";
while (node.length) {
  var realNode = node[0], name = realNode.localName;
  if (!name) break;
  name = name.toLowerCase();

  var parent = node.parent();

  var siblings = parent.children(name);
  if (siblings.length > 1) { 
      name += ':eq(' + siblings.index(realNode) + ')';
  }

  path = name + (path ? '>' + path : '');
  node = parent;
}
var value = path;
$(".selector-input").val(value);

The final iFrame on-load function looks like this:

var url = "<?php echo $url; ?>";
var ssl = false;
var pageIframe;
 
if(url.length > 0){

  if(url.indexOf("https:") !== -1){
    ssl = true
  }
   
  var baseUrl = removeSubdirectoryFromUrlString(url, ssl);
  if(baseUrl.slice(-1) !== "/"){
    baseUrl = baseUrl + "/";
  }

  var style = "<style>.highlighted{background-color:rgba(255, 0, 0, 0.5);} </style>";


  var pageIframe = $('<iframe id="page-iframe" style="" src="/new-page-to-edit.php?baseUrl='+baseUrl+'&url=<?php echo $url; ?>"></iframe>').appendTo(".page-iframe-wrapper");
     
  pageIframe.on('load', function(){

    pageIframe.contents().find("body").prepend(style);
       
    pageIframe.contents().find("body *").mouseenter(function(){

      $(this).addClass('highlighted'); 

      testSelectorEl = $(this);
        
    }).mouseout(function(){

      $(this).removeClass('highlighted');   

    }).click(function(e){

      e.preventDefault();
      e.stopPropagation();
      
      //dig deeper down the dom
      var i = 8;
      while(i > 0){
        if ( $(testSelectorEl).children().length > 0 ) {
          nextEl = $(testSelectorEl).find(">:first-child");
          if(nextEl.is( "style" ) || nextEl.is( "script" ) || nextEl.is( "noscript" )){
             nextEl = $(testSelectorEl).find(">:nth-child(2)");
          }
          if ( !nextEl.is( "u" ) && !nextEl.is( "i" ) && !nextEl.is( "strong" ) && !nextEl.is( "em" )) {
            testSelectorEl = nextEl;
          }
        }
        i--;
      }
      
      var node = testSelectorEl;
      var path = "";
      while (node.length) {
          var realNode = node[0], name = realNode.localName;
          if (!name) break;
          name = name.toLowerCase();

          var parent = node.parent();

          var siblings = parent.children(name);
          if (siblings.length > 1) { 
              name += ':eq(' + siblings.index(realNode) + ')';
          }

          path = name + (path ? '>' + path : '');
          node = parent;
      }
      var value = path;
                
      $(".selector-input").val(value); //for html insert section (redundant for change element section)
      if(! $(".insert-html-wrap").is(':visible')){
        selectNewElement(value); // prepare editor options        
        $(".page-editor-info").offset().top; //scroll user to selector input
      }
      return false;
    });

   
   //make sure images load   
   pageIframe.contents().find("img").each(function(){
      var src = $(this).attr("src");
      if(src && src.length > 0 && src.indexOf("//") == -1){  //if not absolute reference
        if(src.charAt(0) !== "/"){
          src = "/" + src;
        }
        $(this).attr("src", baseUrl + src);
      }
   });
   
   //make sure links aren't followed  
   pageIframe.contents().find("a").each(function(){
      var href = $(this).attr("href");
      $(this).attr("href", "");
      $(this).attr("data-href", href);
   });

   pageIframe.contents().find("body").attr("style", "cursor: pointer !important");
    
   $(".loading-iframe").hide();

  }); //page-iframe load
   
}else{
  //no URL found
   $(".loading-iframe").hide();
}

Lastly, we prepare the editor options panel. This involves setting the original content for the selected element and removing any newly added features (modals, sticky-bars) that were not saved.

Editor Options

After an element is clicked its content and style properties are loaded into the editor panel. The original values are stored in memory. As users edit values a change indicator icon is revealed and the right-panel editor view is updated in real time.

var testSelectorEl;
var testSelectorElPath = "";
var testSelectorElHtml = "";
var testSelectorElImage = "";
var testSelectorElLink = "";
var originalVisibilityState = "";
var originalValues = [];
originalValues['height'] = "";
originalValues['width'] = "";
originalValues['margin'] = "";
originalValues['padding'] = "";
originalValues['border'] = "";
originalValues['font-family'] = "";
originalValues['font-weight'] = "";
originalValues['font-style']= "";
originalValues['text-decoration'] = "";
originalValues['background'] = "";
originalValues['css'] = "";
originalValues['classes'] = "";

$(".html-input").keyup(function(){
    var value = $(this).val();  
    if (value !== testSelectorElHtml){
      $(this).parent().find(".change-indicator").show();
    }else{
      $(this).parent().find(".change-indicator").hide();
    }

    if($(".change-indicator").is(":visible")){
      $(".element-change-save-btn").removeAttr("disabled");
    }else{
      $(".element-change-save-btn").attr("disabled", "disabled");
    }

    var selector = $(".selector-input").val();
    var iFrameDOM = $("iframe#page-iframe").contents()
    iFrameDOM.find(selector).html(value);
    
  });

Besides changing existing elements, new ones can also be added. The “insert content” section, also based on selecting an element, lets users add new text, html, or images before or after whatever has been clicked. In both sections, the CSS selector can also be manually typed (instead of clicking). Adding or editing images is handled by a custom built image upload gallery that leverages AWS S3 and PHP.

insert content

Out-of-the-box elements, such as sticky bars and modals, can also be added with a few clicks and configurations. The HTML and CSS for those are pre-built, with variables for any options that may be set. Any changes made are saved to the database in relation to the user account, project, experiment, and variation.

function addSticky(){
	$conn = $this->connection;
	$accountid = $this->accountid;
	$variationid = $_GET['variationid'];
	$experimentid = $_GET['experimentid'];
	$text = $_POST['text'];
	$color = $_POST['color'];
	$background = $_POST['background'];
	$position = $_POST['position'];
	$linkurl = $_POST['linkurl'];

	$insertStatement = "INSERT INTO `variationchange` (accountid, variationid, experimentid, selector, changetype, changecode, content) VALUES (:accountid, :variationid, :experimentid, :selector, :changetype, :changecode, :content)";
	$changetype = "stickybar";
	$selector = "body";

	$link_opening = "";
	$link_closing = "";

	if(strlen($linkurl) > 0){
		$link_opening = "<a style='color:".$color."' href='".$linkurl."'>";
		$link_closing = "</a>";
	}

	$sticky_html = "<div style='font-weight:bold;".$position.":0;position:fixed;z-index:100000;left:0px;text-align:center;padding:8px 20px;width:100%;background:".$background.";color:".$color."' id='splitwit-sticky'><p style='margin:0px'>".$link_opening.$text.$link_closing."</p></div>";

	$changecode = '$("body").append("'.$sticky_html.'")';

	$stmt = $conn->prepare($insertStatement);	
	$stmt->bindParam(':accountid', $accountid);
	$stmt->bindParam(':variationid', $variationid);
	$stmt->bindParam(':experimentid', $experimentid);
	$stmt->bindParam(':selector', $selector);
	$stmt->bindParam(':changetype', $changetype);
	$stmt->bindParam(':changecode', $changecode);
	$stmt->bindParam(':content', $text);
	$stmt->execute();	

	$this->writeSnippetFile($variationid);
}
		

Writing changes to the snippet

Every project has a unique JavaScript file that users must add to their webpage. The file is hosted by SplitWit, so website owners only need to copy/paste a snippet. The WordPress and Shopify plugins automatically add the snippet, making it even more friendly to non-developers.

Each project may contain multiple experiments with their own changes, metrics, variations, and conditions. After the data is produced by a SQL join statement, it is massaged into a nested object and parsed in JSON. That JavaScript output is concatenated to the necessary libraries and helper functions.

function writeSnippetFile($variationid=false, $experimentid=false, $projectid=false){
			
			$conn = $this->connection;
			$variationid = $variationid || false;
			$experimentid = $experimentid || false;
			
			if(isset($_GET['variationid'])){
				$variationid = $_GET['variationid'];
			}
			if(isset($_GET['experimentid'])){
				$experimentid = $_GET['experimentid'];
			}

			if($variationid){
				$variationid = $_GET['variationid'];
				$sql = "SELECT experiment.projectid FROM `variation` right join `experiment` on variation.experimentid = experiment.experimentid WHERE variationid = ?"; 
				$result = $conn->prepare($sql); 
				$result->execute(array($variationid));
				$experiment_row = $result->fetch(PDO::FETCH_ASSOC);
			}elseif($experimentid){
				$experimentid = $_GET['experimentid'];
				$sql = "SELECT projectid FROM `experiment` WHERE experimentid = ?"; 
				$result = $conn->prepare($sql); 
				$result->execute(array($experimentid));
				$experiment_row = $result->fetch(PDO::FETCH_ASSOC);
			}
			
			if(!$projectid){
				$projectid = $experiment_row['projectid'];
			}

			$sql = "SELECT experiment.experimentid, experiment.status, experimentcondition.experimentconditionid, variation.variationid, variationchange.variationchangeid, variationchange.changecode, variation.css, variation.javascript, experimentcondition.url, experimentcondition.matchtype, experimentcondition.conditiontype, metric.metricid, metric.type, metric.input, metric.urlmatch FROM `experiment` left join `metric` on metric.experimentid = experiment.experimentid join `experimentcondition` on experimentcondition.experimentid = experiment.experimentid join `variation` on variation.experimentid = experiment.experimentid left join `variationchange` on variationchange.variationid = variation.variationid WHERE experiment.projectid = ?"; 
			
			$result = $conn->prepare($sql); 
			$result->execute(array($projectid));
			 
			$experiment_row = $result->fetchAll(PDO::FETCH_ASSOC);
			 
			//turn flat array, into a nested one
			$endResult = array();
			foreach($experiment_row as $row){
			    if (!isset($endResult[$row['experimentid']])){
			        $endResult[$row['experimentid']] = array(
			            'experimentid' => $row['experimentid'],
			            'status' => $row['status'],
			            'conditions' => array(),
			            'variations' => array(),
			            'metrics' => array()
			        );
			    }

			    if (!isset($endResult[$row['experimentid']]['conditions'][$row['experimentconditionid']])){
				    $endResult[$row['experimentid']]['conditions'][$row['experimentconditionid']] = array(
				        'experimentconditionid' => $row['experimentconditionid'],
				        'url' => $row['url'],
				        'matchtype' => $row['matchtype'],
				        'conditiontype' => $row['conditiontype']
				    );
				}

			    if (!isset($endResult[$row['experimentid']]['variations'][$row['variationid']])){
				    $endResult[$row['experimentid']]['variations'][$row['variationid']] = array(
				        'variationid' => $row['variationid'],
				        'javascript' => $row['javascript'],
				        'css' => $row['css'],
				        'changes' => array()
				    );
				}

			    if (!isset($endResult[$row['experimentid']]['variations'][$row['variationid']]['changes'][$row['variationchangeid']])){
				   	$endResult[$row['experimentid']]['variations'][$row['variationid']]['changes'][$row['variationchangeid']] = array(
				        'variationchangeid' => $row['variationchangeid'],
				        'changecode' => $row['changecode']
				    );
				}

			    if (!isset($endResult[$row['experimentid']]['metrics'][$row['metricid']])){
				    $endResult[$row['experimentid']]['metrics'][$row['metricid']] = array(
				        'metricid' => $row['metricid'],
				        'type' => $row['type'],
				        'input' => $row['input'],
				        'urlmatch' => $row['urlmatch']
				    );
				}
				
			}

			$json_output = json_encode($endResult);
			
			// get snippet file name.
			$sql = "SELECT snippet from `project` where projectid=?";
			$result = $conn->prepare($sql); 
			$result->execute(array($projectid));
			$projectrow = $result->fetch(PDO::FETCH_ASSOC);
			$filename = $projectrow['snippet'];
			
			$snippet_template = file_get_contents("/var/www/html/snippet/snippet-template.min.js");
			// concat json to snippet template. write file			
			$myfile = fopen("/var/www/html/snippet/".$filename, "w") or die("Unable to open file!");
			//if there are any animation changes, include the necessary library.
			$txt = "";
			if(strpos($json_output, "addClass('animated") !== false){
				$txt .= 'var head=document.getElementsByTagName("head")[0],link=document.createElement("link");link.rel="stylesheet";link.type="text/css";link.href="https://www.splitwit.com/css/animate.min.css";link.media="all";head.appendChild(link); ';
			}
			$txt .= "window.splitWitExperiments = ".$json_output . "\n" . $snippet_template;
			
			fwrite($myfile, $txt) or die("Unable to save file!");

			fclose($myfile);

	
		}

One of the helper functions is a method that checks if the current URL matches the experiment’s conditions to run on:

function testUrl(testurl, conditions){
			
			if(testurl.search(/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/) < 0){
				return window.inputError($(".test-url-input"), "Please test a valid URL.");
			}
			var valid = false;
			var arr  = [],
		    keys = Object.keys(conditions);

			for(var i=0,n=keys.length;i<n;i++){
			  var key  = keys[i];
			  arr[i] = conditions[key];
			}

			conditions = arr;
			for (i = 0; i < arr.length; i++) { 
				 var url = conditions[i].url;
				 var matchtype = conditions[i].matchtype;
				 var conditiontype = conditions[i].conditiontype;

				 if(matchtype == "exact" && conditiontype == "target" && url == testurl){
				 	valid = true;
				 }
				 if(matchtype == "exact" && conditiontype == "exclude" && url == testurl){
				 	valid = false;
				 }

				 if(matchtype == "basic"){

				 	var cleanTestUrl = testurl.toLowerCase();
				 	var cleanUrl = url.toLowerCase();

				 	if (cleanTestUrl.indexOf("?") > 0) {
	   					cleanTestUrl = cleanTestUrl.substring(0, cleanTestUrl.indexOf("?"));
	   				}
				 	if (cleanUrl.indexOf("?") > 0) {
	   					cleanUrl = cleanUrl.substring(0, cleanUrl.indexOf("?"));
	   				}
				 	if (cleanTestUrl.indexOf("&") > 0) {
	   					cleanTestUrl = cleanTestUrl.substring(0, cleanTestUrl.indexOf("&"));
	   				}
				 	if (cleanUrl.indexOf("&") > 0) {
	   					cleanUrl = cleanUrl.substring(0, cleanUrl.indexOf("&"));
	   				}
				 	if (cleanTestUrl.indexOf("#") > 0) {
	   					cleanTestUrl = cleanTestUrl.substring(0, cleanTestUrl.indexOf("#"));
	   				}
				 	if (cleanUrl.indexOf("#") > 0) {
	   					cleanUrl = cleanUrl.substring(0, cleanUrl.indexOf("#"));
	   				}
	   				cleanTestUrl = cleanTestUrl.replace(/^(?:https?:\/\/)?(?:www\.)?/i, "");
	   				cleanUrl = cleanUrl.replace(/^(?:https?:\/\/)?(?:www\.)?/i, "");
	   				cleanTestUrl = cleanTestUrl.replace(/\/$/, "");
	   				cleanUrl = cleanUrl.replace(/\/$/, ""); 

	   				if(conditiontype == "target" && cleanUrl == cleanTestUrl){
	   					valid = true;
	   				}
	   				if(conditiontype == "exclude" && cleanUrl == cleanTestUrl){
	   					valid = false;
	   				}

				 }
				 
				 if(matchtype == "substring"){
				 	if(testurl.includes(url) && conditiontype == "target"){
	   					valid = true;
				 	}
				 	if(testurl.includes(url) && conditiontype == "exclude"){
	   					valid = false;
				 	}
				 }
				 
			}
			
			return valid;

		}

While the snippet code is specific to this use-case (experimental A/B UI changes), the visual editor can be used in a variety of other contexts. You can look through more of the code I used in this GitHub repository I created.

Build an image upload gallery

media gallery upload with s3

Allowing users to upload images to your app can be a pivotal feature. Many digital products rely on it. This post will show you how to do it using PHP and AWS S3.

image upload gallery

After launching version 1.0 of SplitWit, I decided to enhance the platform by adding features. An important A/B experiment involves swapping images. This is particularly useful on ecommerce stores that sell physical products.

Originally, users could only swap images by entering a URL. To the average website owner, this would seem lame. For SplitWit to be legit, adding images on the fly had to be a feature.

I wrote three scripts – one to upload files, one to fetch them, and one to delete them. Each leverages a standalone PHP class written by Donovan Schönknecht, making it easy to interact with AWS S3. All you’ll need is your S3 bucket name and IAM user credentials. The library provides methods to do everything you need.

AWS S3

Amazon S3 stands for “simple storage service”. It provides data storage that is scalable, secure, highly available, and performant.

A new bucket can be created directly from the management console.

create new s3 bucket

You’ll want to create a new IAM user to programmatically interact with this bucket. Make sure that new user is added to a group that includes the permission policy “AmazonS3FullAccess”. You can find the access key ID and secret in the “Security credentials” tab.

IAM user in AWS with permissions for S3

Uploading image files

When users select an image in the visual editor, they are shown a button to upload a new file. Clicking on it opens the gallery modal.

<div id="image-gallery-modal" class="modal image-gallery-modal" style="display: none;">
  <div class="modal-content">
    <h3>Your image gallery</h3>
    <p><strong>Upload a new file:</strong></p>
    <input class="uploadimage" id="uploadimage" type="file" name="uploadimage" />
    <p class="display-none file-error"></p>
    <div><hr /></div>
    <div class="image-gallery-content"></div>
    <p class="loading-images"><i class="fas fa-spinner fa-spin"></i> Loading images...</p>
  </div>
</div>

The HTML file-type input element presents a browser dialog to select a file. Once selected, the image data is posted to the S3 upload script. The newly uploaded image then replaces the existing image in the visual editor. 

$(".uploadimage").change(function(){
    
    var file = $(this)[0].files[0];   
    var formData = new FormData();
    formData.append("file", file, file.name);
    formData.append("upload_file", true);         

    $.ajax({
      type: "POST",
      url: "/s3-upload.php",
      xhr: function () {
        var myXhr = $.ajaxSettings.xhr();
        if (myXhr.upload) {
            // myXhr.upload.addEventListener('progress', that.progressHandling, false);
        }
        return myXhr;
      },
      success: function (response) {
        console.log(response);
        
        document.getElementById("uploadimage").value = "";

        if(response !== "success"){
          $(".file-error").text(response).show();
          setTimeout(function(){ $(".file-error").fadeOut();}, 3000)
          return;
        }
        
        $("#image-gallery-modal").hide();
        loadS3images();
        var newImageUrl = "https://splitwit-image-upload.s3.amazonaws.com/<?php echo $_SESSION['userid'];?>/" + file.name;
        $("input.img-url").val(newImageUrl);
        $(".image-preview").attr("src", newImageUrl).show();
        $(".image-label .change-indicator").show();

        //update editor (right side)
        var selector = $(".selector-input").val();
        var iFrameDOM = $("iframe#page-iframe").contents()
        if($(".element-change-wrap").is(":visible")){
          iFrameDOM.find(selector).attr("src", newImageUrl).attr("srcset", "");
          $(".element-change-save-btn").removeAttr("disabled");
        }
        if($(".insert-html-wrap").is(":visible")){
          var position = $(".position-select").val();
          var htmlInsertText = "<img style='display: block; margin: 10px auto;' class='htmlInsertText' src='"+newImageUrl+"'>";
          iFrameDOM.find(".htmlInsertText").remove();
          if(position == "before"){
            iFrameDOM.find(selector).before(htmlInsertText);
          }
          if(position == "after"){
            iFrameDOM.find(selector).after(htmlInsertText);
          }
        }
      },
      error: function (error) {
        console.log("error: ");
        console.log(error);
      },
      async: true,
      data: formData,
      cache: false,
      contentType: false,
      processData: false,
      timeout: 60000
  });

});

The upload script puts files in the same S3 bucket, under a separate sub-directory for each user account ID. It checks the MIME type on the file to make sure an image is being uploaded.

<?php
require 's3.php';
 
$s3 = new S3("XXXX", "XXXX"); //access key ID and secret

// echo "S3::listBuckets(): ".print_r($s3->listBuckets(), 1)."\n";

$bucketName = 'image-upload';

if(isset($_FILES['file'])){
	$file_name = $_FILES['file']['name'];   
	$uploadFile = $_FILES['file']['tmp_name']; 

	if ($_FILES['file']['size'] > 5000000) { //5 megabyte
     	   echo 'Exceeded filesize limit.';
     	   die();
    	}
    	$finfo = new finfo(FILEINFO_MIME_TYPE);
	if (false === $ext = array_search(
	        $finfo->file($uploadFile),
	        array(
	            'jpg' => 'image/jpeg',
	            'png' => 'image/png',
	            'gif' => 'image/gif',
	        ),
	        true
	    )) {
	    	if($_FILES['file']['type'] == ""){
	    		echo 'File format not found. Please re-save the file.';
	    	}else{
		    	echo 'Invalid file format.';
		    }
     	    die();
	 }

	//create new directory with account ID, if it doesn't already exist
	session_start();
	$account_id = $_SESSION['userid'];

	if ($s3->putObjectFile($uploadFile, $bucketName, $account_id."/".$file_name, S3::ACL_PUBLIC_READ)) {
		echo "success";
	}

}
?>

After upload, the gallery list is reloaded by the loadS3images() function.

Fetching image files from S3

When the image gallery modal first shows, that same loadS3images() runs to populate any images that have been previously uploaded.

function loadS3images(){

  $.ajax({
      url:"/s3-get-objects.php",
      complete: function(response){
        gotImages = true;
        $(".loading-images").hide();
        var data = JSON.parse(response.responseText);
        var x;
        var html = "<p><strong>Select existing file:</strong></p>";
        var l = 0;
        for (x in data) {
          l++;
          var name = data[x]["name"];
          nameArr = name.split("/");
          name = nameArr[1];
          var imgUrl = "https://splitwit-image-upload.s3.amazonaws.com/<?php echo $_SESSION['userid'];?>/" + name;
          html += "<div class='image-data-wrap'><p class='filename'>"+name+"</p><img style='width:50px;display:block;margin:10px;' src='' class='display-none'><button type='button' class='btn select-image'>Select</button> <button type='button' class='btn preview-image'>Preview</button> <button type='button' class='btn delete-image'>Delete</button><hr /></div>"
        }
        if(l){
          $(".image-gallery-content").html(html);
        }

      }
    });
}

It hits the “get objects” PHP script to pull the files in the account’s directory.

<?php
require 's3.php';
 
$s3 = new S3("XXX", "XXX"); //access key ID and secret

$bucketName = 'image-upload';
session_start();
$account_id = $_SESSION['userid'];
$info = $s3->getBucket($bucketName, $account_id);
echo json_encode($info);

?>

Existing images can be chosen to replace the one currently selected in the editor. There are also options to preview and delete.

Delete an S3 object

When the delete button is pressed for a file in the image gallery, all we need to do is pass the filename along. If the image is currently being used, we also remove it from the editor.

$(".image-gallery-content").on("click", ".delete-image", function() {
    var parent = $(this).parent();
    var filename = parent.find(".filename").text();
    var currentImageUrl = $(".img-url").val();
    if(currentImageUrl =="https://splitwit-image-upload.s3.amazonaws.com/<?php echo $_SESSION['userid'];?>/" + filename){
      $(".img-url").val(testSelectorElImage);
      $(".image-preview").attr("src", testSelectorElImage);
      var selector = $(".selector-input").val();
      var iFrameDOM = $("iframe#page-iframe").contents()
      iFrameDOM.find(selector).attr("src", testSelectorElImage);
    }
    $.ajax({
      method:"POST",
      data: { 
        'filename': filename, 
      },
      url: "/s3-delete.php?filename="+filename,
      complete: function(response){
        parent.remove();
        if(!$(".image-data-wrap").length){
          $(".image-gallery-content").html("");
        }
      }
    })

}); 

 

<?php
require 's3.php';
 
$s3 = new S3("XXX", "XXX"); //access key ID and secret

$bucketName = 'image-upload';
session_start();
$account_id = $_SESSION['userid'];
$filename = $_POST['filename'];
if ($s3->deleteObject($bucketName, $account_id."/".$filename) ){
	echo "S3::deleteObject(): Deleted file\n";
}

?>

 

Building a SAAS for A/B testing

Product development and SAAS

SplitWit is a digital product. It is a “software as a service” platform that helps split test websites and apps. That means it allows us to make changes to a website, that only half of visitors will see, and then determine which version has better results (sales, sign-ups, etc.).

Foundational code and design

I used a template to quickly get things prototyped and working. It came with a user account engine to handle registration, login, and more.

The front-end design utilizes basic principles that focus on user experience. I iterated through various color pallets, and ended with a blue-shaded scheme. Subtle textured patterns applied to background sections help add a finished look. And of course, FontAwesome is my go-to icon set.

SplitWit.com
https://www.SplitWit.com

I used a CSS rule on the main container of each page to have a minimum height of 100% of the viewport. This ensures that the page footer doesn’t end up in the middle of the screen if there is not enough content.

.main-content.container{
  min-height: 100vh;
}

The contact form at the bottom of the homepage is powered by AWS SES.

Visual optimizer and editor

After setting up an account, users can create experiments that target certain pages of a website. The visual optimizer lets changes be made easily between the control and variation versions.

visual editor

The editor loads up a website as an iFrame on the right side of the page. Once a page is loaded, SplitWit adds an overlay to the iFrame. This way, instead of interacting with the page, clicks can be intercepted. Any elements that get clicked are loaded up as HTML into the “make a change” section of the editor. Any changes made are saved to that variation, and will be displayed to half of visitors.

Here is an example of the code that powers the overlay and connects it to the editor:

pageIframe.contents().find("body").prepend(overlay);
 
pageIframe.contents().find("body *").css("z-index", 1).mouseenter(function(){
  $(this).addClass('highlighted'); 
  testSelectorEl = $(this);
  
}).mouseout(function(){

  $(this).removeClass('highlighted');   

}).click(function(e){

  e.preventDefault();
  var value = testSelectorEl.getPath()
  selectNewElement(value);
  //scroll user to selector input
  $([document.documentElement, document.body]).animate({
    scrollTop: $(".page-editor-info").offset().top
  }, 1000);

});

function selectNewElement(value){
  
    testSelectorElPath = value;
    testSelectorEl = pageIframe.contents().find(value);
    $(".change-indicator").hide()
    $(".el-input").removeAttr("disabled");
    $(".element-change-save-btn").attr("disabled", "disabled");
    $(".find-selector").hide();
    $(".element-change-wrap .selector-input").val(testSelectorElPath);

    $(".toggable-section").hide();
    $(".element-change-wrap").show();
    $(".multiple-elements").hide();

    if(testSelectorEl.attr("src") && testSelectorEl.attr("src").length > 0){
      $(".img-url").val(testSelectorEl.attr("src"));
      $(".img-url-wrap").show();
      testSelectorElImage = testSelectorEl.attr("src");
    }else{
      testSelectorElImage = "";
      $(".img-url").val("");
      $(".img-url-wrap").hide();
    }
    if(testSelectorEl.attr("href") && testSelectorEl.attr("href").length > 0){
      $(".link-url").val(testSelectorEl.attr("href"));
      $(".link-url-wrap").show();
      testSelectorElLink = testSelectorEl.attr("href");
    }else{
      testSelectorElLink = "";
      $(".link-url").val("");
      $(".link-url-wrap").hide();
    }

    if(testSelectorEl.html() && testSelectorEl.html().length > 0){
      $(".html-input").val(testSelectorEl.html());
      $(".html-input-wrap").show();
      testSelectorElHtml = testSelectorEl.html();
    }else{
      testSelectorElHtml = "";
      $(".html-input").val("");
      $(".html-input-wrap").hide();
    }

    $(".elem-css-group").show();
    if(testSelectorEl.is(":visible")){
      originalVisibilityState = "visible";
      $("#visible-radio").attr("checked", "checked");
      $("#hidden-radio").removeAttr("checked");
    }else{
      originalVisibilityState = "hidden";
      $("#hidden-radio").attr("checked", "checked");
      $("#visible-radio").removeAttr("checked");

    }
    originalValues['height'] = testSelectorEl.css("height");
    $(".height").val(originalValues['height']);
    originalValues['width'] = testSelectorEl.css("width");
    $(".width").val(originalValues['width']);
    originalValues['border'] = testSelectorEl.css("border");
    $(".border").val(originalValues['border']);
    originalValues['font-family'] = testSelectorEl.css("font-family");
    $(".font-family").val(originalValues['font-family']);
    originalValues['font-size'] = testSelectorEl.css("font-size");
    $(".font-size").val(originalValues['font-size']);
    originalValues['font-weight'] = testSelectorEl.css("font-weight");
    $(".font-weight").val(originalValues['font-weight']);
    originalValues['font-style']= testSelectorEl.css("font-style");
    $(".font-style").val(originalValues['font-style'])
    originalValues['text-decoration'] = testSelectorEl.css("text-decoration")
    $(".text-decoration").val(originalValues['text-decoration'])
    originalValues['background'] = "";
    $(".background").val(originalValues['background'])

} //end selectNewElement()

The editor has lots of built in options, so users can change the style and behavior of a page without needing to know how to code. A marketer can use this tool without the help of a developer.

Metrics and statistical significance

A key feature of SplitWit is to measure conversion metrics and performance indicators. The platform determines which variation is a winner based on the metrics set. Three types of metrics are offered: page views, click events, and custom API calls.

Algorithms calculate statistical significance based on the number of visitors an experiment receives and the conversion metrics configured. This makes sure that the result is very unlikely to have occurred coincidently.

The code snippet

Each project setup in SplitWit generates a code snippet. Once this snippet is added to a website, SplitWit is able to do its magic. Using JavaScript, it applies variation changes, splits user traffic between versions, and measures key metrics about the experiments running.

The platform uses a relational database structure. As changes are made to experiments, the details are saved and written to a unique snippet file. When the snippet file loads, the first thing is does is check to see if there are any experiments that should be running on the current page. Each experiment can be configured to run on various URLs. The configuration rules contain three parts: a URL pattern, a type (target or exclude), and a match type (exact, basic, or substring). You can read SplitWit documentation to find an explanation of these match types.

experiment settings

Here is the code used to test a URL against an experiment’s configuration rules:

function testUrl(testurl, conditions){
			
	if(testurl.search(/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/) < 0){
		return window.inputError($(".test-url-input"), "Please test a valid URL.");
	}
	var valid = false;
	var arr  = [],
	keys = Object.keys(conditions);

	for(var i=0,n=keys.length;i<n;i++){
		var key  = keys[i];
		arr[i] = conditions[key];
	}

	conditions = arr;
	for (i = 0; i < arr.length; i++) { 
		var url = conditions[i].url;
		var matchtype = conditions[i].matchtype;
		var conditiontype = conditions[i].conditiontype;

		if(matchtype == "exact" && conditiontype == "target" && url == testurl){
			valid = true;
		}
		if(matchtype == "exact" && conditiontype == "exclude" && url == testurl){
			valid = false;
		}

		if(matchtype == "basic"){
			var cleanTestUrl = testurl.toLowerCase();
			var cleanUrl = url.toLowerCase();

			if(cleanTestUrl.indexOf("?") > 0) {
				cleanTestUrl = cleanTestUrl.substring(0, cleanTestUrl.indexOf("?"));
			}
			if(cleanUrl.indexOf("?") > 0) {
				cleanUrl = cleanUrl.substring(0, cleanUrl.indexOf("?"));
			}
			if(cleanTestUrl.indexOf("&") > 0) {
				cleanTestUrl = cleanTestUrl.substring(0, cleanTestUrl.indexOf("&"));
			}
			if(cleanUrl.indexOf("&") > 0) {
				cleanUrl = cleanUrl.substring(0, cleanUrl.indexOf("&"));
			}
			if(cleanTestUrl.indexOf("#") > 0) {
				cleanTestUrl = cleanTestUrl.substring(0, cleanTestUrl.indexOf("#"));
			}
			if(cleanUrl.indexOf("#") > 0) {
				cleanUrl = cleanUrl.substring(0, cleanUrl.indexOf("#"));
			}
			cleanTestUrl = cleanTestUrl.replace(/^(?:https?:\/\/)?(?:www\.)?/i, "");
			cleanUrl = cleanUrl.replace(/^(?:https?:\/\/)?(?:www\.)?/i, "");
			cleanTestUrl = cleanTestUrl.replace(/\/$/, "");
			cleanUrl = cleanUrl.replace(/\/$/, ""); 

			if(conditiontype == "target" && cleanUrl == cleanTestUrl){
				valid = true;
			}
			if(conditiontype == "exclude" && cleanUrl == cleanTestUrl){
				valid = false;
			}

		}
		if(matchtype == "substring"){
			if(testurl.includes(url) && conditiontype == "target"){
				valid = true;
			}
			if(testurl.includes(url) && conditiontype == "exclude"){
				valid = false;
			}
		} 
	}
	
	return valid;

}

Subscription billing workflow

Stripe is used to bill customers. In the billing dashboard we can create a product, and assign it a monthly pricing plan.

Subscription products

The payment processor handles re-billing customers each month. Our software is responsible for keeping track of each account’s payment status. In the database we record the date of when an account will be considered delinquent. Upon registration each account has this field set to 15 days in the future, affording a two week trial. At this point, users have not entered any credit card information.

Initial payment

Stripe’s JavaScript SDK is used during initial payment to tokenize credit card information before passing it along to the server.

activate your subscription
Stripe’s JS library handles card validation and tokenization.

Below is the HTML used for a Stripe payment element:

<div id="stripe-payment-modal" class="modal stripe-payment-modal" style="display: none;">

	<!-- Modal content -->
	<div class="modal-content">
		<p>
		  <button type="button" class="dismiss-modal close" >&times;</button>
		</p>
		<p>Activate your account subscription.</p>
		<form id="payment-form">
		  <div class="form-row">
		    <!-- <label for="card-element">
		      Credit or debit card
		    </label> -->
		    <div id="card-element">
		      <!-- A Stripe Element will be inserted here. -->
		    </div>

		    <!-- Used to display Element errors. -->
		    <div id="card-errors" role="alert"></div>
		  </div>

		  <button type="button" class="btn submit-payment">Submit Payment</button>
		</form>

  	</div>

</div>

And the JavaScript:

<script src="https://js.stripe.com/v3/"></script>
<script type="text/javascript">
var stripe = Stripe('your-public-key-goes-here');

var elements = stripe.elements();

// Custom styling can be passed to options when creating an Element.
var style = {
  base: {
    color: '#32325d',
    fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
    fontSmoothing: 'antialiased',
    fontSize: '16px',
    '::placeholder': {
      color: '#aab7c4'
    }
  },
  invalid: {
    color: '#fa755a',
    iconColor: '#fa755a'
  }
};

// Create an instance of the card Element.
var card = elements.create('card', {style: style});

// Add an instance of the card Element into the `card-element` div.
card.mount('#card-element');

// Handle real-time validation errors from the card Element.
card.addEventListener('change', function(event) {
  var displayError = document.getElementById('card-errors');
  if (event.error) {
    displayError.textContent = event.error.message;
  } else {
    displayError.textContent = '';
  }
});

// Handle form submission.
var form = document.getElementById('payment-form');
form.addEventListener('submit', function(event) {
  event.preventDefault();

  stripe.createToken(card).then(function(result) {
    if (result.error) {
      // Inform the user if there was an error.
      var errorElement = document.getElementById('card-errors');
      errorElement.textContent = result.error.message;
    } else {
      // Send the token to your server.
      stripeTokenHandler(result.token);
    }
  });
});

// Submit the form with the token ID.
function stripeTokenHandler(token) {
  // Insert the token ID into the form so it gets submitted to the server
  var form = document.getElementById('payment-form');
  var hiddenInput = document.createElement('input');
  hiddenInput.setAttribute('type', 'hidden');
  hiddenInput.setAttribute('name', 'stripeToken');
  hiddenInput.setAttribute('value', token.id);
  form.appendChild(hiddenInput);
  
  var data = $("#payment-form").serialize();
  $.ajax({
  	url:"stripe-payment-service.php",
  	method: "POST",
  	data: data,
  	complete: function(response){
  		console.log(response);
  		window.location.reload();
  	}
  })
}

$(".submit-payment").click(function(){
	stripe.createToken(card).then(function(result) {
    if (result.error) {
    	// Inform the customer that there was an error.
    	var errorElement = document.getElementById('card-errors');
    	errorElement.textContent = result.error.message;
    } else {
	$(".submit-payment").attr("disabled", "disabled").html('Working...');
      	// Send the token to your server.
      	stripeTokenHandler(result.token);
    }
  });
});
</script>

The above code creates a new Stripe object using a public API key. That object injects a credit card form into our ‘#card-element’ div, with custom styles attached. It listens for any changes, and displays validation errors. When the form is submitted, the Stripe object creates a token from the payment information. That token is passed to our back-end. Stripe’s PHP library is used to finish the transaction:

<?php
function subscribe(){
        require_once('stripe-php-6.43.0/init.php');
        \Stripe\Stripe::setApiKey('sk_XXXX');
	$stripe_token = $_POST['stripeToken'];
	$conn = $this->connection;
	
	if(isset($_SESSION['email'])){
		$email = $_SESSION['email'];
	}else{
		die("No email found.");
	}
	
	if(strlen($email)>0){
		$sql = "SELECT * FROM `account` WHERE email = ?"; 
		$result = $conn->prepare($sql); 
		$result->execute(array($email));
		$row = $result->fetch(PDO::FETCH_ASSOC);
	}
	$customer_id = $row['stripe_customer_id'];
	//check if this account already has a stripe_customer_id
	if(strlen($customer_id) < 1){
		//if not, create the customer
		$customer = \Stripe\Customer::create([
		  'email' => $email,
		  'source' => $stripe_token,
		]);
		$customer_id = $customer['id'];
		//write stripe ID to db
		$sql = "UPDATE `account_table` SET stripe_customer_id = ? WHERE email = ?"; 
		$result = $conn->prepare($sql); 
		$result->execute(array($customer_id, $email));
	}

	// Create the subscription
	$subscription = \Stripe\Subscription::create([
	  'customer' => $customer_id,
	  'items' => [
	    [
	      'plan' => 'plan_XXX', //setup in Stripe dashboard.
	    ],
	  ],
	  'expand' => ['latest_invoice.payment_intent'],
	  'billing_cycle_anchor' => time()
	]);
	$subscription_status = $subscription['status'];
	$subscription_id = $subscription['id'];
	if($subscription_status == "active"){
		//set current_period_end to 32 days (1 month plus some leeway) in the future. set past_due as false 
		$sql = "UPDATE `account_table` SET stripe_subscription_id = ?, current_period_end = ?, past_due = 0 WHERE email = ?"; 
		$result = $conn->prepare($sql);
		$past_due = false;
		$current_period_end = new DateTime;  
		$current_period_end->modify( '+32 day' );
		$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 
		$result->execute(array($subscription_id, $current_period_end, $email));
	}

}
?>

On the server side our secret API key is used. A customer record is created in Stripe using the payment token and user’s email. The Stripe customer ID is then used to create a subscription. We record the the customer ID and subscription ID to our database. The account’s new subscription period end is updated to 32 days in the future.

Cancel a subscription

The user is able to cancel their subscription from the SplitWit account dashboard.

cancel subscription

We retrieve their subscription from Stripe, and cancel it, using their subscription ID. They will no longer be billed. We update our database to turn off the account’s experiments, delete any Stripe details, mark their subscription as delinquent, and re-write their snippet file.

<?php

function cancelSubscription(){
	require_once('stripe-php-6.43.0/init.php');
        \Stripe\Stripe::setApiKey('sk_XXXX');

	$conn = $this->connection;
	if(isset($_SESSION['userid'])){
		$accountid = $_SESSION['userid'];
	}else{
		die("No userid found.");
	}
	
	if(strlen($accountid)>0){
		$sql = "SELECT * FROM `account` WHERE accountid = ?"; 
		$result = $conn->prepare($sql); 
		$result->execute(array($accountid));
		$row = $result->fetch(PDO::FETCH_ASSOC);
	}
	$stripe_subscription_id = $row['stripe_subscription_id'];
	$subscription = \Stripe\Subscription::retrieve($stripe_subscription_id);
	$subscription->cancel();
	
	//turn off experiments and update snippets. clear stripe IDs. set current_period_end to yesterday. set past_due = 1
	$current_period_end   = new DateTime;  
	$current_period_end->modify( '-1 day' );
	$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 
	$sql = "UPDATE `account` SET stripe_customer_id = '', stripe_subscription_id = '', past_due = 1, current_period_end = ? WHERE accountid = ?"; 
	$result = $conn->prepare($sql); 
	$result->execute(array($current_period_end, $accountid));

	//turn off all experiments
	$status = "Not running";
	$sql = "UPDATE `experiment` set status = ? where accountid = ?";
	$result2 = $conn->prepare($sql); 
	$result2->execute(array($status, $accountid));

	//update all snippets for this account (1 snippet per project)
	$sql = "SELECT * FROM `project` WHERE accountid = ?";
	$result3 = $conn->prepare($sql); 
	$result3->execute(array($accountid));
	$rows3 = $result3->fetchAll(PDO::FETCH_ASSOC);
	foreach ($rows3 as $key3 => $value3) {
		$projectid = $value3['projectid'];
    	        $databaseProjectService = new DatabaseProjectService();
		$databaseProjectService -> writeSnippetFile(false, false, $projectid);
	}

	$this->status = "complete";
}

?>

Re-billing subscriptions

As long as an account has an active subscription in Stripe, they will be automatically re-billed each month. When this event takes place, Stripe can deliver data about it to an end-point of our choice (commonly known as a webhook).

stripe webhooks

SplitWit listens for an event called “invoice.payment_succeeded”, which occurs when a customer’s monthly payment is successful. When that happens the account’s subscription period end is updated to 32 days in the future.

<?php
function webhookPaymentSuccess(){
	require_once('stripe-php-6.43.0/init.php');
	\Stripe\Stripe::setApiKey('sk_XXX');
	$payload = @file_get_contents("php://input");
	
	$endpoint_secret = "whsec_XXX";

	$sig_header = $_SERVER["HTTP_STRIPE_SIGNATURE"];
	$event = null;

	try {
	  $event = \Stripe\Webhook::constructEvent(
	    $payload, $sig_header, $endpoint_secret
	  );
	} catch(\UnexpectedValueException $e) {
	  // Invalid payload
	  http_response_code(400); // PHP 5.4 or greater
	  exit();
	} catch(\Stripe\Error\SignatureVerification $e) {
	  // Invalid signature
	  http_response_code(400); // PHP 5.4 or greater
	  exit();
	}
	
	if($event->type == 'invoice.payment_succeeded'){

		$invoice = $event->data->object;
		$customer_id = $invoice['customer'];
		//update their accocunt current_period_end
		$conn = $this->connection;
		$sql = "UPDATE `account` SET  current_period_end = ?, past_due = 0 WHERE stripe_customer_id = ?"; 
		$result = $conn->prepare($sql);
		$past_due = false;
		$current_period_end = new DateTime;  
		$current_period_end->modify( '+32 day' );
		$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 
		$result->execute(array($current_period_end, $customer_id));
	}else{
		http_response_code(400);
	        exit();
	}
	
	http_response_code(200);
}

?>

What if payment fails or never happens? The account’s subscription period end never gets updated.

A daily scheduled task checks each active account’s subscription period end date. If that date is in the past, we mark the account as past due, turn off all experiments, and update its snippet files.

The value of experimentation

Driving digital conversions is a science. Experimentation should be a constant exercise in this respect. Take any field and we can benefit from testing the waters and adjusting our sail. Our ability to interpret that data is the bottle neck to making good decisions. The best lesson I’ve learned is that intuition is usually not enough. It’s better to look at the numbers and trust data.

Influencing users through a funnel of action, finally leading to a conversion, is a challenge. Optimizing conversions, sales, and leads can be broken down into a system based approach.  SplitWit focuses on that point.

www.SplitWit.com