CSS Grid: Border Between Each Row

CSS Grid borders

User Interface Layout with CSS

CSS Grid is a layout system that allows you to design web interfaces with rows and columns. In earlier times, developers used less elegant techniques to arrange UI elements. In the 1990s, using HTML tables was the standard way. Many of my earliest clients used tables for their entire website and email newsletter code bases. During that time Dreamweaver was a popular product that let designers build websites and generated source code that was mostly tables. The semantic problem what this approach was that we were using tables for non-tabular data.

By the 2000s, the CSS float property became popular for multi-column layouts. We didn’t see Flexbox until the mid 2010s. You can read about examples of its use in the layout of my blog  and an image carousel I built.

CSS Grid

Grid is the latest addition to the toolbox of options. For a recent project I used CSS Grid to create a two column web application. Here is some example HTML:

<h1>Below is a grid of data</h1>
<div style="display: grid; grid-template-columns:repeat(2, 1fr); grid-gap: 24px">
	<div>Row 1, Column 1</div>
	<div>Row 1, Column 2</div>

	<div>Row 2, Column 1</div>
	<div>Row 2, Column 2</div>

	<div>Row 3, Column 1</div>
	<div>Row 3, Column 2</div>
</div>

The grid container has a property: `grid-template-columns:repeat(2, 1fr);`. The CSS property grid-template-columns is used to define the number and size of columns in a CSS Grid layout. This repeat() function is used to repeat a pattern a specified number of times. In this case, the pattern is defined by the second argument, 1fr. fr stands for “fractional unit.” This property is describing two columns, with each item taking up an equal amount of space. This is what it looks like:

grid css layout

I wanted a horizontal line to separate each row. Usually, just adding a border-bottom to each of the grid items will work. In this scenario, the grid design called for a grid-gap property that forced the border to break.

css grid-gap

We fixed this by adding a new grid item between each “row” (that is, every third element). Those elements would represent each border line. I set the grid-column property of those elements to span two columns.

<style>
	.gridItemBorder{
		border-bottom: 2px solid #333;
		grid-column: span 2;
	}
</style>


<h1>Below is a grid of data</h1>
<div style="display: grid; grid-template-columns:repeat(2, 1fr); grid-gap: 24px">
	<div class="gridItem">Row 1, Column 1</div>
	<div class="gridItem">Row 1, Column 2</div>

	<div class="gridItemBorder"></div>

	<div class="gridItem">Row 2, Column 1</div>
	<div class="gridItem">Row 2, Column 2</div>

	<div class="gridItemBorder"></div>

	<div class="gridItem">Row 3, Column 1</div>
	<div class="gridItem">Row 3, Column 2</div>
</div>

The end result is just what we wanted:

css grid layout with row separators

Background Parallax Webpage with CSS

parallax website CSS

Parallax scrolling is a technique where the background moves at a different speed than the foreground. Ideally, the effect has many layers: a background, a mid-ground, and a foreground. These layers moving at different speeds create the illusion of depth and immersion

  • The background layer forms the foundation of the parallax effect. It typically consists of large, visually captivating images or patterns that set the scene and establish the mood of the website.
  • The mid-ground layer serves as an intermediary between the background and foreground, providing additional visual interest and depth.
  • The foreground layer contains the primary content and interactive elements that users engage with directly.

My favorite example comes from the classic Sonic the Hedgehog game on the Sega Genesis. In that game, the background layer encompasses lush landscapes, while the intermediate layer consists of trees and obstacles that add depth to the scene. Sonic and collectibles represent the foreground layer, with Sonic’s speedy movements contrasting the slower-paced background and intermediate layers.

HTML & CSS Parallax Effect

We can use HTML and CSS to achieve a parallax scrolling effect by manipulating the position and properties of background images or layers.

HTML:

<div class="parallax-container">
  <div class="parallax-background"></div>
  <div class="content">
    <!-- Your content here -->
  </div>
</div>

CSS:

.parallax-container {
  position: relative;
  overflow-x: hidden;
  overflow-y: auto; /* Enable vertical scrolling */
  height: 100vh; /* Set the container height to full viewport height */
}

.parallax-background {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 200%; /* Adjust height to create a taller background for parallax effect */
  background-image: url('background.jpg');
  background-size: cover;
  background-position: center;
  z-index: -1; /* Ensure background is behind content */
}

.content {
  padding: 20px;
  /* Other styles for your content */
}

  1. The .parallax-container serves as the main container for the parallax effect. It has relative positioning to contain the absolutely positioned background layer.
  2. The .parallax-background div contains the background image. It’s absolutely positioned to cover the entire container and set behind the content with a negative z-index.
  3. Adjusting the height of .parallax-background to be taller than the container creates the parallax effect when the user scrolls.
  4. The .content div holds the content and is positioned over the parallax background.

My Background Webpage

A few years ago, I made a single webpage to describe my professional background and experience. I decided to use a parallax background effect for this page. You can visit it here.

I really love the way the main logo text initially seems to blend into the foreground but remains static as you scroll. Upon scrolling, you’ll observe that the top text remains fixed while the second line moves independently, creating a distinct visual effect. It feels unexpected. The contrasting image backgrounds creates a fun juxtaposition.

And, it’s all done with CSS (no JavaScript necessary). You can create a simple parallax effect by adjusting the positioning of background images or layers using CSS properties like background-position and background-attachment. This allows the background to move at a different rate than the foreground content as the user scrolls, creating the illusion of depth.

Here is the code that I used to build this example:

<!doctype html>
<html class="no-js" lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<link rel="shortcut icon" href="https://www.antpace.com/favicon.ico" />
<link rel="apple-touch-icon-precomposed" sizes="144x144" href="https://www.antpace.com/apple-touch-icon-144x144-precomposed.png">
<link rel="apple-touch-icon-precomposed" sizes="114x114" href="https://www.antpace.com/apple-touch-icon-114x114-precomposed.png">
<link rel="apple-touch-icon-precomposed" sizes="72x72" href="https://www.antpace.com/apple-touch-icon-72x72-precomposed.png">
<link rel="apple-touch-icon-precomposed" sizes="57x57" href="https://www.antpace.com/apple-touch-icon-57x57-precomposed.png">
<link rel="apple-touch-icon-precomposed" href="https://www.antpace.com/apple-touch-icon-precomposed.png">
<title>Anthony Pace Background and Experience</title>
<meta name="description" content="Anthony Pace is a web developer, designer, and database architect. His daily work includes writing software and creating user-centered experiences. The technologies that he use most frequently are HTML5, CSS3, Javascript, PHP, and MySql.">
<meta name="keywords" content="Anthony Pace, web, development, design, marketing, websites, creative services, Bronx, New York">
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.5.0/css/all.css" integrity="sha384-B4dIYHKNBt8Bc12p+WXckhzcICo0wtJAoU8YZTY5qE0Id1GSseTk6S+L3BlXeVIU" crossorigin="anonymous">
<link href='https://fonts.googleapis.com/css?family=Lobster+Two:700&v2' rel='stylesheet' type='text/css'>
 	<script type="application/ld+json">
	    {
	      "@context": "http://schema.org",
	      "@type": "BreadcrumbList",
	       "itemListElement": [
	            {
	                "@type": "ListItem",
	                "position": 1,
	                "item": {
	                  "@id": "https://www.antpace.com",
	                  "name": "Home",
	                  "image": "https://www.antpace.com/images/anthony-pace-logo.webp"
	                }
	            }, 
	            {
	                "@type": "ListItem",
	                "position": 2,
	                "item": {
	                  "@id": "https://www.antpace.com/background",
	                  "name": "Background",
	                  "image": "https://www.antpace.com/background/logo.webp"
	                }
	            } 
	        ]
	    }
	  </script>
	  <script type="application/ld+json">
	    {
	        "@context": "http://schema.org",
	        "@type": "WebPage",
	        "name": "Anthony Pace Background & Experience",
	        "description": "The page describes Anthony's professional background and experience.",
	        "lastReviewed": "<?php echo date("Y-m-d", time() - 60 * 60 * 24); ?>",
	        "reviewedBy": {
	            "@type": "Person",
	            "name": "Anthony Pace"
	        },
	        "publisher":{
	            "@type":"Organization",
	            "url":"https://www.antpace.com",
	            "name":"AntPace",
	            "logo":{
	                "@type":"ImageObject",
	                "url":"https://www.antpace.com/images/anthony-pace-logo.webp",
	                "width":"490px",
	                "height":"230px"
	            }
	        }
	    }
	    </script>
  <style>
  	body { 
		font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
		font-size: 16px;
		background:#34495e;
		margin: 0px;
	} 
	 
	 
	article h1 { font-family: 'Lobster Two'; font-size: 60px; margin: 25px 0; line-height: 1em; }

	.story { height: 1000px; padding: 0; margin: 0; width: 100%; max-width: 1920px; position: relative; margin: 0 auto; border-top: 1px solid rgba(255,255,255,0.3); border-bottom: 1px solid rgba(0,0,0,0.4); box-shadow: 0 0 50px rgba(0,0,0,0.8);}

	#first { background: url(images/textured.webp) 50% 0 repeat fixed; }
	#second { background: url(images/ufo.webp) 50% 0 no-repeat fixed; }
	#fourth { background: url(images/abstract2.webp) 50% 0 no-repeat fixed; }
	#third { background: url(images/desertEarthSet.webp) 50% 0 no-repeat fixed; }
	#last { background: url(images/textured.webp) 50% bottom repeat fixed; }

	/* Introduction */
	#first #antpaceLogoBgDiv { background: url(/images/anthony-pace-logo.webp) 50% 100px no-repeat fixed; min-height: 1000px; padding: 0; margin: 0; width: 100%; max-width: 1920px; position: relative; margin: 0 auto; }
	#first article { width: 100%; top: 300px; position: absolute; text-align: center; }
	#first article p,
	#first article a { color: #ccc; }
	#first article a { text-decoration: underline; }
	#first article a:hover { color: #fff; }

	 
	#second { padding: 50px 0;}
	#second article { 
		 color: #fff; 
		 width: 445px;
		 margin-left: 100px; 
		 padding: 10px 20px; 
		 text-shadow: 0 -1px 0 rgba(0,0,0,0.5); 
		 line-height: 1.5em; 
		 box-shadow: 0 0 25px rgba(0,0,0,0.3); 
		 border: 1px solid rgba(150,150,150,0.1); 
	}
	#second article p { margin-bottom: 25px; }
	#second article a { color: #ff0;}

	 
	#third article {
		background:  #333; 
		color: #fff; 
		padding: 10px 20px; 
		margin: 100px 0 0 60%; 
		text-shadow: 0 -1px 0 rgba(0,0,0,0.5); 
		line-height: 1.5em; 
		color: #fff; 
		position: absolute; 
		top: 0; box-shadow: 0 0 25px rgba(0,0,0,0.3); 
		border: 1px solid rgba(150,150,150,0.1); 
	 }

	#third article p { width: 300px; margin-bottom: 25px; }




	#fourth article {  background:  #333;margin-left: 10%; text-shadow: 0 -1px 0 rgba(0,0,0,0.5); line-height: 1.5em; color: #fff; position: absolute; top: 0; }
	#fourth article p { width: 300px; margin: 50px 0; }
	#fourth img { position: fixed; left: 50%; box-shadow: 0 0 25px rgba(0,0,0,0.7); z-index: 1; border:5px solid white;}

	/* The End */
	#last .last { background: url(images/thanks.webp) 50% 100px no-repeat fixed; height: 1000px; padding: 0; margin: 0; width: 100%; max-width: 1920px; position: relative; margin: 0 auto; }
 


	@media (max-width: 625px) {
		#last .last {
			background-size:contain;
		}
	}
	@media (max-width: 711px) {
		#experienceAndBackgroundImg{
			width:100%
		}
	}
	@media (max-width: 639px) {
		#antpaceLogoBgDiv{
			background-size: contain !important;
		}
	}


	@media (max-width: 555px) {
		#second article {
			margin-left:0px;
			padding:0px;
			width:100%;
			border:none;
		}
		#second p, #second h1 {
			padding:6px 12px;
		}
	}

	@media (max-width: 885px) {
		#third article {
			margin: 100px 0 0 10%; 
			 
		}
	}
	@media (max-width: 422px) {
		#third article {
			margin: 100px 0 0 0; 
			padding:0px;
			width:100%;
			 
			 
		}
		#third article p{
			width:auto;
			padding:6px 12px;
		}
		#third article h1{
			font-size:50px;
			 
		}
		 
	}  
	@media (max-width: 767px){
		footer {
			text-align: center;
		}
	}
	#third article p, #second article p{
		text-align:justify;
	}


  </style>
  
</head>

<body>

  <div id="main" role="main">

	<!-- Section #1 / Intro -->
	<section id="first" class="story" data-speed="8" data-type="background">    	
		<div id="antpaceLogoBgDiv" data-type="sprite" data-offsetY="100" data-Xposition="50%" data-speed="-2"></div>		
		<article>

			<img id="experienceAndBackgroundImg" src="images/experienceAndBackground.webp" alt="Experience and Background"  />
			<p>Anthony Pace - <a style="text-decoration: none;" href="mailto:info@antpace.com">info@antpace.com</a> - <a style="text-decoration: none;" href="tel:6465330334">646-533-0334</a></p>
			
	    </article>
	</section>

	<!-- Section #2 / Background Only -->
	<section id="second" class="story" data-speed="4" data-type="background">
		<article>
			<h1>Experience</h1>
		    	<p>I love coding and visual design. I've been doing both for over two decades. I am a web developer, designer, and database architect. My daily work includes writing software and creating user-centered experiences. <strong>The technologies that I use most frequently are HTML5, CSS3, Javascript, PHP, and MySQL.</strong> </p>
		    	<p>Currently, I am employed by a company in New York City where I directly manage other developers. I am in charge of developing apps (native and HTML5) and building &amp; maintaining data driven websites. I also play a large role in the strategy and implementation of marketing campaigns.  </p>
				<p> My position as marketing manager emphasizes measuring the effectiveness of our marketing actions, and then making decisions based on the data. I use advanced A/B testing, google analytics, email campaign reports, and user surveys to figure out what works best for the company. I also manage the logistics of our presence at live exhibitions.
				</p>
		    			
		</article>
	</section>
	
	<!-- Section #3 / Photos -->
	<section id="third" class="story" data-speed="6" data-type="background" data-offsetY="250">    	
		 
    	<article>
    		<h1>Background</h1>
	    	<div class="textbox">    	
		    	<p>In primary school I began experimenting with HTML as well as creating applications with Visual Basic. I quickly learned about various software design concepts and built a strong interest in Computer Science. Despite this, in college I studied philosophy. The abstract and logical thinking techniques I fostered have applied very much to my career in technology. </p>
		    	<p>Since then, I've continued to educate myself about computer science. Books and online lectures facilitate me moving towards a level of expertise relevant to my career. Social media and online communities help me to keep up on the latest technologies and industry trends.</p>
				<p>When finished with college I created a web design and marketing business. I built clientele using both digital and traditional marketing strategies. I've been a professional developer since 2008.</p>
				
		    </div>
    	</article>
	</section>	
	

	<!-- Section #4 / HTML5 Video -->
	<!--<section id="fourth" class="story" data-speed="8" data-type="background" data-offsetY="250">
    	<article>
    		<h2>Skills</h2>
	    	<div class="textbox">    	
		    	<p>I have acquired and honed a variety of abilities as a technologist. I am a full stack web developer with a passion for programming. Some of the most useful skills that I have built include:
					<ul>
						<li>Project Management
						<li>Business Management
						<li>Design
						<li>Software Engineering
						<li>Marketing
					</ul>
				</p>For a more technical description of my abilites please visit my <a style="color:white;" href="http://antpace.com/resume" target="_blank">resum&eacute;</a>.</p>
		   
		    </div>
    	</article>
		 
	</section>	-->

	<!-- Section #5 / The End-->
	<section id="last" data-speed="8" data-type="background" data-offsetY="250">    
	
		<a href="/blog/"><div class="last" data-type="sprite" data-offsetY="-1600" data-Xposition="50%" data-speed="-2"></div>	</a>
		<p style=" text-align: center; padding: 50px;font-size: 24px;"><a href="/blog/about/" style="color:white; text-decoration: none;">Continue to my blog &nbsp;&nbsp;<i class="fas fa-arrow-right"></i></a></p>
	</section>
	
  </div> <!-- // End of #main -->
  
<?php include $_SERVER["DOCUMENT_ROOT"] . '/footer-shared.php'; ?>
<?php include $_SERVER["DOCUMENT_ROOT"] . '/components/analytics.php'; ?>

</body>
</html>

Take a look:

References:

  1. https://www.clickrain.com/blog/parallax-scrolling-examples-and-history
  2. https://en.wikipedia.org/wiki/Parallax_scrolling
  3. https://www.w3schools.com/howto/howto_css_parallax.asp

Debugging Mobile CSS on Chrome for Android

debug mobile css on Android

On this website (this very one you are reading) I have a resume page. On it, I display a timeline styled with CSS. I use the border-radius property to create circles for the years on that timeline. The years connect between <div> elements that represent my work experience.

timeline displayed on a resume webpage
An image can be worth a forty-two words

It looks great (at least,  I think so). Except on mobile. Specifically, on Android running Chrome. The circle appears oblongly squished.

Broken CSS on Android
Broken CSS on Android

I could not recreate this bug on my laptop, even when I used Chrome Developer Tools to simulate mobile. Not being able to reproduce a bug consistently is a very frustrating experience.

Chrome developer tools
I couldn’t recreate the mobile bug on my laptop. *Sigh*

USB debugging

I would have to use remote debugging via Chrome DevTools. The first step was to enable “Developer Options” on my Android device. I was using a S21 Galaxy Ultra. On this device I navigated to “Settings” -> “About Phone” -> “Software information”. Then I triple tapped the “Build number” and received a toast message “Developer mode activated”. This feels familiar, as it has been a similar process on previous Android devices that I have owned. I’d post a picture of my software information screen, but am worried that some of the data could be used maliciously by strangers on the internet.

“Developer options” was revealed in my “Settings” app. From there, I could turn on “USB debugging”

usb debugging in the developer options menu

Now, I could connect my Android to my MacBook. From my MacBook, I opened Chrome and navigated to chrome://inspect/#devices

view usb connected devices running chrome

As long as my Android also had Chrome running, I could see my device listed with any open browser tabs. Clicking “inspect” opened a new window on my laptop that mirrored my cellphone’s screen. When I scrolled on one, it reflected on the other.

Inspect mobile device from desktop

CSS Solution

I could inspect elements from the developer tools panel. This was exactly what I needed to debug the problem. And, the fix ended up being pretty easy. Here’s the original, relevant CSS:

.timeline .year {
  font-size: 1em; 
  line-height: 4;
  border-radius: 50%;
  width: 6em; 
}

The issue was ultimately with the width and height properties. The circles look correct on my desktop browser because the width and height rendered to nearly identical – 64px by 66px. To get a circle with CSS you need an element with the same height and width (the greater the difference, the less even and more stretched the circle appears), and then set the border-radius to 50%. On Android, that same element was being rendered as 64px by 85px. This was most probably happening because the height was not being explicitly defined.

As a remedy, I hard-coded the width and height properties. I changed the unit of measurement to pixels for both.

.timeline .year {
  font-size: 1em; 
  line-height: 4;
  border-radius: 50%;
  width: 65px;
  height: 65px;
}

This fixed the circle from being stretched. But, now the text was not centered that way that it should be.

Fixed CSS displays a circle

That was happening because of the line-height property. Since it was set without a unit of measure, most browsers would interpret it to mean a multiplier of the current font-size. And, the font-size was also using a relative unit of measure, em. Right away, that was a code smell. It was a browser issue, where along the way values were not being calculated as expected.

On mobile, the font-size was being calculated to 20.8px. On desktop, the font-was was being calculated to 16px. I fixed this by changing the line-height value to an absolute unit of measure:

.timeline .year {
  font-size: 1em; 
  line-height: 65px;
  border-radius: 50%;
  width: 65px;
  height: 65px;
}

Sort an HTML Table Using JavaScript

Sort an HTML Table Using JavaScript

For a recent side project I was tasked with enhancing an existing HTML table. That table displayed search results. The records were dynamic, populated by an AJAX call after the “search” button was pressed. One of the requests was to let users click on the column headers to sort the table. Each click would organize the data, toggling ascending and descending, based on the column values.

A table with data about dogs

My first idea was to use a front-end library. I love abstractions, and hate reinventing the wheel. I’ve used the DataTables jQuery plug-in before, and thought it might be a good fit. All I had to do was include two CDN file references – one for CSS styles and another for JavaScript functionality. After that, I could select the table by ID and call a single function:

<link href='//cdn.datatables.net/1.13.2/css/jquery.dataTables.min.css' rel='stylesheet' type='text/css'>
<script src="//cdn.datatables.net/1.13.2/js/jquery.dataTables.min.js"></script>

<script>
$(document).ready( function () {
	$('#dog-table').DataTable();
} );
</script>

This added quick and easily out-of-the-box functionality, with bells and whistles.

It seemed great, but the extras that it added, such as pagination and front-end search were unnecessary, and actually got in the way of the design specification. Those might be easy enough to clean up by passing options into the method call, or even with a bit of custom CSS, but still seemed like overkill.

Further, the DataTables library would occasionally throw errors about an “incorrect column count”, depending on what results my back-end provided. That was because the data model, and possible front-end actions for this app were more complex that you might think. The HTML wasn’t strictly semantic and the information wasn’t just tabular.

The more I thought about it, the more I felt that plain vanilla JavaScript should be enough to get the job done. And, since we’re living in the future, I decided to ask an AI chat-bot called ChatGPT.

I asked “How can I use jQuery to sort an HTML table by a specific column?”

Asking ChatGPT about coding

It told me that “You can use the sort method of JavaScript arrays combined with the map method to sort an HTML table by a specific column,” and gave me a specific code example!

I was very impressed. Here is the JavaScript code it provided:

<script>
$(document).ready(function() {
  var table = $('#myTable tbody tr').get();

  table.sort(function(a, b) {
    var A = $(a).children('td').eq(1).text();
    var B = $(b).children('td').eq(1).text();

    if(A < B) {
      return -1;
    }

    if(A > B) {
      return 1;
    }

    return 0;
  });

  $.each(table, function(index, row) {
    $('#myTable').children('tbody').append(row);
  });
});
</script>

I added this code to a click-handler in my app, after adjusting the element selectors. Although it worked (kind of), it did not operate quite as I expected. It only performed the sort on a single column, and did not alternate the order on each click.

I continued to ask the chat-bot more questions, making refinements to the functionality. I wanted the code to toggle between ascending and descending on each click. Also, I figured it could be nice to avoid jQuery completely and just use basic JS.

Chat bot solving code problems

Eventually, it told me “To toggle between ascending and descending order when sorting the table, you can keep track of the current sorting order for each column in a separate array”. Below, you can see the full code implementation:

<style>
  table {
  border-collapse: collapse;
  width: 100%;
}

th, td {
  text-align: left;
  padding: 8px;
  border-bottom: 1px solid #ddd;
}

tr:nth-child(even) {
  background-color: #f2f2f2;
}

th {
  background-color: #4CAF50;
  color: white;
  cursor: pointer;
}

td:first-child {
  font-weight: bold;
}

td:nth-child(3), td:nth-child(4) {
  text-transform: capitalize;
}
#search-input {
  padding: 8px;
  margin-bottom: 12px;
  width: 100%;
  box-sizing: border-box;
  border: 2px solid #ccc;
  border-radius: 4px;
  font-size: 16px;
}

#search-input:focus {
  outline: none;
  border-color: #4CAF50;
}
</style>
<input type="text" id="search-input" placeholder="Search breeds...">
<button>Search</button>
<table id="dog-table">
  <thead>
    <tr>
      <th>Breed</th>
      <th>Origin</th>
      <th>Size</th>
      <th>Temperament</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Labrador Retriever</td>
      <td>Canada</td>
      <td>Large</td>
      <td>Friendly, outgoing, and active</td>
    </tr>
    <tr>
      <td>German Shepherd</td>
      <td>Germany</td>
      <td>Large</td>
      <td>Loyal, confident, and courageous</td>
    </tr>
    <tr>
      <td>Poodle</td>
      <td>France</td>
      <td>Small to Large</td>
      <td>Intelligent, elegant, and proud</td>
    </tr>
    <tr>
      <td>Bulldog</td>
      <td>England</td>
      <td>Medium</td>
      <td>Determined, friendly, and calm</td>
    </tr>
    <tr>
      <td>Beagle</td>
      <td>England</td>
      <td>Small to Medium</td>
      <td>Cheerful, determined, and friendly</td>
    </tr>
  </tbody>
</table>


<script>
// Get the table element
const table = document.querySelector('table');

// Get the header row and its cells
const headerRow = table.querySelector('thead tr');
const headerCells = headerRow.querySelectorAll('th');

// Get the table body and its rows
const tableBody = table.querySelector('tbody');
const tableRows = tableBody.querySelectorAll('tr');

// Initialize sort order for each column
let sortOrders = Array.from(headerCells).map(() => null);

// Attach a click event listener to each header cell
headerCells.forEach((headerCell, index) => {
  headerCell.addEventListener('click', () => {
    // Extract the column index of the clicked header cell
    const clickedColumnIndex = index;
    
    // Toggle the sort order for the clicked column
    if (sortOrders[clickedColumnIndex] === 'asc') {
      sortOrders[clickedColumnIndex] = 'desc';
    } else {
      sortOrders[clickedColumnIndex] = 'asc';
    }
    
    // Sort the rows based on the values in the clicked column and the sort order
    const sortedRows = Array.from(tableRows).sort((rowA, rowB) => {
      const valueA = rowA.cells[clickedColumnIndex].textContent;
      const valueB = rowB.cells[clickedColumnIndex].textContent;
      const sortOrder = sortOrders[clickedColumnIndex];
      const compareResult = valueA.localeCompare(valueB, undefined, { numeric: true });
      return sortOrder === 'asc' ? compareResult : -compareResult;
    });
    
    // Rebuild the table in the sorted order
    tableBody.append(...sortedRows);
  });
});

</script>

Using predictive language models as a coding assistant is very helpful. I can’t wait to see what other uses we find for this technology, especially as it gets better.

CSS for Weighted Hyperlink Decoration

CSS for weighted hyperlink decoration

How to add an underline to website text should be covered in any intro to web development course. The old-fashioned HTML way uses a now-deprecated tag:

<u>This will appear underlined!</u>

Modern approaches use CSS to define such a style:

<p style="text-decoration: underline;">This will appear underlined!</p>

Even better, properly written code will separate the inline styles, like so:

<style>
.underlined-text{
  text-decoration: underline;
}
</style>
<p class="underlined-text">This will appear underlined!</p>

For hyperlink text, I might want to hide the underline when a user mouses over it. That’s easy with the “hover” pseudo-class:

<style>
a.hyperlink-text{
  text-decoration: underline;
}
a.hyperlink-text:hover{
  text-decoration: none;
}
</style>
<a class="hyperlink-text">This will appear underlined!</a>

But, suppose I want to have that underline to become thicker instead of disappearing. That will require an advanced, super-secret CSS technique. To make it work, we will utilize box-shadow.

In the world of cascading style sheets, the box-shadow property adds a shadow effect around any HTML element. The shadow is described by its offsets, relative to the element. Leveraging this, we can create a shadow that looks like an underline. On hover, we can adjust the settings to change its appearance:

<style>
a.hyperlink-text{
  text-decoration: none;
  box-shadow: inset 0 -1px 0 rgb(15 15 15);
}
a.hyperlink-text:hover{
  -webkit-box-shadow: inset 0 0 0 rgb(0 0 0 / 0%), 0 3px 0 rgb(0 0 0);
  box-shadow: inset 0 0 0 rgb(0 0 0 / 0%), 0 3px 0 rgb(0 0 0);
}
</style>
<a class="hyperlink-text">This will appear underlined!</a>

Point your cursor over any of the hyperlinks in this post to see what it looks like. Experiment with the above code to make it your own.

How to create a Shopify app with PHP

Build a Shopify App with PHP

Developing a marketplace app for your SAAS will grow organic traffic and lets users find you. Potential customers can discover your digital product where they are already looking. Software services that occupy the eCommerce space have a chance to help Shopify store owners grow their businesses. Creating a public Shopify app benefits both developers and the merchant user-base in the Shopify App Store ecosystem.

Recently, Shopify announced that it is decreasing the share of profit that it takes from developers. Each year, developers keep all of their revenue up to the first one-million dollars.

Why Build a Marketplace App?

The short answer: Discoverability.

A few years ago, I built a fitness tracking app for a niche sport. It was a hobby project to better track my BJJ training.  Since then, I continue to average ~10 registrations weekly without any marketing efforts.

Consistent BJJ Tracker sign-ups are driven from Google Play. Even though it is only a web app (PWA), I was able to bundle it into an APK file using Trusted Web Activities and Digital Asset Links. Having an app listed in a marketplace leads to new users finding it naturally.

My next side project was a SAAS for split testing & conversion optimization. It helps websites A/B test to figure out what front-end changes lead to more sales, sign-ups, etc. The Shopify App Store was as perfect fit to attract shop owners to use SplitWit. I decided to build a public, embedded, Shopify app to reach new prospects.

I’ll explain how I did it, along with examples of building another one, all using PHP. This guide will make launching a Shopify App Store app easy, fast, repeatable.

SplitWit on Shopify

Creating a Public Shopify App with PHP

Embedded Shopify apps display in a merchant’s admin dashboard. They are meant to “add functionality to Shopify stores“. They are hosted on the developer’s infrastructure and are loaded via iFrame within Shopify. You can create a new app in your Shopify Partners dashboard to get started.

create a shopify app

Since the SplitWit SAAS already existed as a subscription web app built on the LAMP stack, I only had to handle Shopify specific authorization and payments. I could essentially load the existing app in the dashboard’s iFrame after authentication. The new code I wrote contains methods for checking installation status, building the oAuth URL, subscribing users to recurring application charges, and more.

When I created my next Shopify app, Click to Call, I leveraged that code and refactored it to be more reusable. Configurable parameters let me set the database and API credentials dynamically.

Step 1: Check Installation Status

When the app’s url loads in the merchant admin dashboard, the first step is to check installation status for that shop.

public function checkInstallationStatus(){
	$conn = $this->conn;
	$shop = $_GET['shop'];

	//check if app is already installed or not
	$statement = $conn->prepare("SELECT * FROM `shopify_installation_complete` WHERE shop = :shop");
	$statement->execute(['shop' => $shop]);
	$count = $statement->rowCount();
	if($count == 0){
		//app is not yet installed
		return false;
	}else{
		//app is already installed
		$row = $statement->fetch();
		return $row;
	}

}

You’ll notice we don’t hit any Shopify API for this. Instead, our own database is queried. We manually track if the app has already been installed in a MySql table.

shopify installation record in a mysql database table

Two database tables are used to manage the Shopify App installation:

CREATE TABLE IF NOT EXISTS `shopify_authorization_redirect` (
    `shopify_authorization_redirect_id` int NOT NULL AUTO_INCREMENT,
    `shop` varchar(200),
    `nonce` varchar(500),
    `scopes` varchar(500),
    created_date datetime DEFAULT CURRENT_TIMESTAMP,
    updated_date datetime ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`shopify_authorization_redirect_id`)
);

 
CREATE TABLE IF NOT EXISTS `shopify_installation_complete` (
    `shopify_installation_complete_id` int NOT NULL AUTO_INCREMENT,
    `splitwit_account_id` int,
    `splitwit_project_id` int,
    `shop` varchar(200),
    `access_token` varchar(200),
    `scope` varchar(200),
    `expires_in` int,
    `associated_user_scope` varchar(200),
    `associated_user_id` BIGINT,
    `associated_user_first_name` varchar(200),
    `associated_user_last_name` varchar(200),
    `associated_user_email` varchar(200),
    `associated_user_email_verified` varchar(10),
    `associated_user_account_owner` varchar(10),
    `associated_user_account_locale` varchar(10),
    `associated_user_account_collaborator` varchar(10),
    created_date datetime DEFAULT CURRENT_TIMESTAMP,
    updated_date datetime ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`shopify_installation_complete_id`)
);

The root file (index.php) looks like this:

<?php

require '/var/www/html/service-layer/shopify-app-service.php';
include 'shopify-creds.php'; // $api_key, $secret, $app_db, $app_slug
use SplitWit\ShopifyService\ShopifyService;
$shopify_service = new ShopifyService($api_key, $secret, $app_db);

$already_installed = $shopify_service->checkInstallationStatus();

if(!$already_installed){
    $install_redirect_url = $shopify_service->buildAuthorizationUrl(false, $app_slug);
}else{
    $install_redirect_url = $shopify_service->buildAuthorizationUrl(true, $app_slug);
}
header('Location: ' . $install_redirect_url );

?>

After determining the merchant’s installation status, the next step is to authenticate them.

oAuth Authentication

The Shopify developer resources explain “how to ask for permission” with oAuth. The merchant user needs to be redirected to a Shopify URL:

https://{shop}.myshopify.com/admin/oauth/authorize?client_id={api_key}&scope={scopes}&redirect_uri={redirect_uri}&state={nonce}&grant_options[]={access_mode}

I wrote a PHP class “ShopifyService” (shopify-app-service.php) to handle all of the Shopify specific logic. The method buildAuthorizationUrl() builds the Shopify authorization URL. It accepts a boolean parameter set according to the merchant’s installation status. That value toggles the authorization URL’s redirect URI, directing the code flow through either first-time installation or re-authentication.

The built URL includes query params: an API key, a nonce, the scope of permission being requested, and a redirect URI. The shop name is the subdomain, and can be pulled as GET data delivered to your app.

The API key can be found in the developer’s partner dashboard in the app’s overview page.

A nonce (“number used once”) is used as an oAuth state parameter. It serves to “link requests and callbacks to prevent cross-site request forgery attacks.” We save that random value in our database to check against during the oAuth callback.

The redirect URI (the oAuth callback) is dynamic based on the users installation status.

public function buildAuthorizationUrl($reauth = false, $slug= "shopify-app"){
	$conn = $this->conn;
	$requestData = $this->requestData;
	$scopes = "write_script_tags"; //write_orders,read_customers, read_content
	$nonce = bin2hex(random_bytes(10));
	$shop = $requestData['shop'];

	//first check if there is already a record for this shop. If there is, delete it first.
	$statement = $conn->prepare("SELECT * FROM `shopify_authorization_redirect` WHERE shop = :shop");
	$statement->execute(['shop' => $shop]);
	$count = $statement->rowCount();
	
	if($count > 0){
		$statement = $conn->prepare("DELETE FROM `shopify_authorization_redirect` WHERE shop = :shop");
		$statement->execute(['shop' => $shop]);
	}

	$statement = $conn->prepare("INSERT INTO `shopify_authorization_redirect` (shop, nonce, scopes) VALUES (:shop, :nonce, :scopes)");
	$statement->bindParam(':shop', $shop);
	$statement->bindParam(':nonce', $nonce);
	$statement->bindParam(':scopes', $scopes);
	$statement->execute();

	
	$redirect_uri = "https://www.splitwit.com/".$slug."/authorize-application";
	
	if($reauth){ //change the redirect URI
		$redirect_uri = "https://www.splitwit.com/".$slug."/reauthorize-application";
	}

	$redirect_url = "https://".$shop."/admin/oauth/authorize?client_id=". $this->api_key ."&scope=".$scopes."&redirect_uri=". $redirect_uri ."&state=".$nonce . "&grant_options[]=per-user";

	return $redirect_url;

}

Both possible redirect URLs needed to be white-listed in the “App setup” page.

white listed URLs

A location header routes the user. If the app hasn’t been installed yet, Shopify prompts the merchant to confirm authorization.

install shopify app

If the app was already installed then the oAuth grant screen is skipped entirely and the merchant is immediately routed to the /reauthorize-application resource instead (and ultimately lands on the app home screen).

Install App & Register the Merchant User

What actually happens when “Install app” is clicked?  The user is redirected from Shopify permissions screen back to our app ( /authorize-application ), calling the authorizeApplication() function. That method receives four values as GET parameters: ‘shop’, ‘state’, ‘hmac’, ‘code’

The ‘shop’ name is used to look up the nonce value we saved when the Shopify authorization URL was first built. We compare it to the ‘state’ parameter. This security step ensures that the callback request is valid and is not fraudulent. We also check that the shop name provided matches a valid Shopify hostname. Here is the relevant code, pulled from authorizeApplication():

$conn = $this->conn; 
$requestData = $this->requestData;
$requiredKeys = ['code', 'hmac', 'state', 'shop'];
foreach ($requiredKeys as $required) {
    if (!in_array($required, array_keys($requestData))) {
        throw new Exception("The provided request data is missing one of the following keys: " . implode(', ', $requiredKeys));
    }
}

//lookup and validate nonce
$shop = $requestData['shop'];

$statement = $conn->prepare("SELECT * FROM `shopify_authorization_redirect` WHERE shop = :shop");
$statement->execute(['shop' => $shop]);
$count = $statement->rowCount();
if($count == 0){
    throw new Exception("Nonce not found for this shop.");
}
$row = $statement->fetch();
$nonce = $row['nonce'];
//

//make sure the 'state' parameter provided matches the stored nonce
$state = $requestData['state'];
if($state !== $nonce){
    throw new Exception("Nonce does not match provided state.");
}
//

//validate the shop name
$pattern = "/[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com[\/]?/";
if(!preg_match($pattern, $shop)) {
    throw new Exception("The shop name is an invalid Shopify hostname.");
}

Every request or redirect from Shopify” includes a HMAC value that can be used to verify its authenticity. Here is how I do it in PHP:

public function verifyHmac($requestData){
	// verify HMAC signature. 
	// https://help.shopify.com/api/getting-started/authentication/oauth#verification
	if( !isset($requestData['hmac'])){
		return false;
	}

	$hmacSource = [];

	foreach ($requestData as $key => $value) {
	    
	    if ($key === 'hmac') { continue; }

	    // Replace the characters as specified by Shopify in the keys and values
	    $valuePatterns = [
	        '&' => '%26',
	        '%' => '%25',
	    ];
	    $keyPatterns = array_merge($valuePatterns, ['=' => '%3D']);
	    $key = str_replace(array_keys($keyPatterns), array_values($keyPatterns), $key);
	    $value = str_replace(array_keys($valuePatterns), array_values($valuePatterns), $value);

	    $hmacSource[] = $key . '=' . $value;
	}

	sort($hmacSource);
	$hmacBase = implode('&', $hmacSource);
	$hmacString = hash_hmac('sha256', $hmacBase, $this->secret);
	// Verify that the signatures match
    if ($hmacString !== $requestData['hmac']) {
        return false;
    }else{
    	return true;
    }
}

That method is called in the class construct function, to be sure it happens every time.

The ‘code’ parameter is the access code. It is exchanged for an access token by sending a request to the shop’s access_token endpoint. We record that token to the ‘shopify_installation_complete’ table along with relevant data.

To fully complete the installation, app specific project records are saved. For SplitWit, this means a user-account is created along with an initial project. Any JavaScript tags are injected onto the merchant’s site using the Shopify admin API ScriptTag resource. Linking a privately hosted JS file, unique to each merchant, allows our app to dynamically update the shop website. You can learn about how that snippet tag works in another post that explains how the visual editor is built.

Lastly, a webhook is created to listen for when this app in uninstalled ( ‘topic’ => ‘app/uninstalled’ ) in order to call our “uninstallApplication” method. Webhooks allow you to listen for certain events in a shop, and run code based on data about what happened.

Once installation is complete, our server returns a header that reloads the app.

shopify app

Below is the original authorizeApplication() method. Eventually, I moved app specific logic into its own files after refactoring this class to support another SAAS, Click to Call.

public function authorizeApplication(){
	$conn = $this->conn; 
	$requestData = $this->requestData;
	$requiredKeys = ['code', 'hmac', 'state', 'shop'];
        foreach ($requiredKeys as $required) {
           if (!in_array($required, array_keys($requestData))) {
             throw new Exception("The provided request data is missing one of the following keys: " . implode(', ', $requiredKeys));
           }
        }

	//lookup and validate nonce
	$shop = $requestData['shop'];
	
	$statement = $conn->prepare("SELECT * FROM `shopify_authorization_redirect` WHERE shop = :shop");
	$statement->execute(['shop' => $shop]);
	$count = $statement->rowCount();
	if($count == 0){
        throw new Exception("Nonce not found for this shop.");
	}
	$row = $statement->fetch();
	$nonce = $row['nonce'];
	//
	
	//make sure the 'state' parameter provided matches the stored nonce
	$state = $requestData['state'];
	if($state !== $nonce){
        throw new Exception("Nonce does not match provided state.");
	}
	//
	
	//validate the shop name
	$pattern = "/[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com[\/]?/";
	if(!preg_match($pattern, $shop)) {
        throw new Exception("The shop name is an invalid Shopify hostname.");
	}
	//

	$already_installed = $this->checkInstallationStatus();
	//if it is already installed, then lets update the access token 
        if(!$already_installed){
    	  //install the app
    	
	  //exchange the access code for an access token by sending a request to the shop’s access_token endpoint
	  $code = $requestData['code'];
	  $post_url = "https://" . $shop . "/admin/oauth/access_token";
		
	  $params = [
            'client_id'    => $this->api_key,
            'client_secret'    => $this->secret,
            'code'    => $code
          ];

          $curl_response_json = $this->curlApiUrl($post_url, $params);
	  $access_token = $curl_response_json['access_token'];
		
          $statement = $conn->prepare("INSERT INTO `shopify_installation_complete` (shop, access_token, scope, expires_in, associated_user_scope, associated_user_id, associated_user_first_name, associated_user_last_name, associated_user_email, associated_user_email_verified, associated_user_account_owner, associated_user_account_locale, associated_user_account_collaborator) VALUES (:shop, :access_token, :scope, :expires_in, :associated_user_scope, :associated_user_id, :associated_user_first_name, :associated_user_last_name, :associated_user_email, :associated_user_email_verified, :associated_user_account_owner, :associated_user_account_locale, :associated_user_account_collaborator)");
		
	  $statement->bindParam(':shop', $shop);
		
	  $statement->bindParam(':access_token', $access_token);
	  $statement->bindParam(':scope', $curl_response_json['scope']);
	  $statement->bindParam(':expires_in', $curl_response_json['expires_in']);
	  $statement->bindParam(':associated_user_scope', $curl_response_json['associated_user_scope']);
	  $statement->bindParam(':associated_user_id', $curl_response_json['associated_user']['id']);
	  $statement->bindParam(':associated_user_first_name', $curl_response_json['associated_user']['first_name']);
	  $statement->bindParam(':associated_user_last_name', $curl_response_json['associated_user']['last_name']);
	  $statement->bindParam(':associated_user_email', $curl_response_json['associated_user']['email']);
	  $statement->bindParam(':associated_user_email_verified', $curl_response_json['associated_user']['email_verified']);
	  $statement->bindParam(':associated_user_account_owner', $curl_response_json['associated_user']['account_owner']);
	  $statement->bindParam(':associated_user_account_locale', $curl_response_json['associated_user']['locale']);
	  $statement->bindParam(':associated_user_account_collaborator', $curl_response_json['associated_user']['collaborator']);

	  $statement->execute();
	  $installation_complete_id = $conn->lastInsertId();
		 
	  if(isset($curl_response_json['associated_user']['email']) && strlen($curl_response_json['associated_user']['email']) > 0){

		$store_name = explode(".", $shop);
		$store_name = ucfirst($store_name[0]);

		//create account
		$method = "thirdPartyAuth";
		$user_service_url = "https://www.splitwit.com/service-layer/user-service.php?third_party_source=shopify&method=" . $method . "&email=".$curl_response_json['associated_user']['email']."&companyname=" .$store_name . "&first=" . $curl_response_json['associated_user']['first_name'] . "&last=" . $curl_response_json['associated_user']['last_name'] ;
			
		$params = [];

		$curl_user_response_json = $this->curlApiUrl($user_service_url, $params);
		
		$account_id = $curl_user_response_json['userid']; 
			
		$method = "createProject";
			
		$project_service_url = "https://www.splitwit.com/service-layer/project-service.php?method=" . $method . "&accountid=" . $account_id;

		$params = [
	            'projectname'    => $store_name . " Shopify",
	            'projectdomain'    => "https://".$shop,
	            'projectdescription'    => ""
	        ];

		$curl_project_response_json = $this->curlApiUrl($project_service_url, $params);
		$project_id = $curl_project_response_json['projectid'];
		$snippet = $curl_project_response_json['snippet'];
			
			

		//inject JS snippet into site
		// https://shopify.dev/docs/admin-api/rest/reference/online-store/scripttag#create-2020-04
		$create_script_tag_url = "https://" . $this->api_key . ":" . $this->secret . "@" . $shop . "/admin/api/2020-04/script_tags.json";
		$params = [
                    'script_tag' => [
                     'event' => 'onload',
                     'src' => 'https://www.splitwit.com/snippet/' . $snippet
                    ]
        	];

        	$headers = array(
		  'X-Shopify-Access-Token:' . $access_token,
		  'content-type: application/json'
		);

		$json_string_params = json_encode($params);

		$create_script_curl_response_json = $this->curlApiUrl($create_script_tag_url, $json_string_params, $headers);
		
		//shopify app should only ever have access to this one project.
		//write accountID and ProjectID to this shopify_installation_complete record.

		$statement = $conn->prepare("UPDATE `shopify_installation_complete` SET splitwit_account_id = ?, splitwit_project_id = ? WHERE shopify_installation_complete_id = ?");

		$statement->execute(array($account_id, $project_id, $installation_complete_id));
			
    	}
		
    	//create webhook to listen for when app in uninstalled.
	//https://{username}:{password}@{shop}.myshopify.com/admin/api/{api-version}/{resource}.json
	// https://shopify.dev/docs/admin-api/rest/reference/events/webhook#create-2020-04
	$create_webhook_url = "https://" . $this->api_key . ":" . $this->secret . "@" . $shop . "/admin/api/2020-04/webhooks.json";
	$params = [
                'webhook' => [
                    'topic' => 'app/uninstalled',
                    'address' => 'https://www.splitwit.com/service-layer/shopify-app-service?method=uninstallApplication',
                    'format' => 'json'
                ]
        ];

	$headers = array(
		'X-Shopify-Access-Token:' . $access_token,
		'content-type: application/json'
	);
		
	$json_string_params = json_encode($params);

    	$create_webhook_curl_response_json = $this->curlApiUrl($create_webhook_url, $json_string_params, $headers);
		
	//installation complete.
   }

   header('Location: ' . "https://" . $shop . "/admin/apps/splitwit");
	
}

You’ll notice that I call a custom method that abstracts the PHP cURL (client URL library) methods. This helps me avoid repeating the same code in multiple places.

public function curlApiUrl($url, $params, $headers = false, $use_post = true, $use_delete = false, $use_put = false){
		
	$curl_connection = curl_init();
	// curl_setopt($curl_connection, CURLOPT_FOLLOWLOCATION, true);
	if($headers){
	    curl_setopt($curl_connection, CURLOPT_HTTPHEADER, $headers);
	    curl_setopt($curl_connection, CURLOPT_HEADER, false);
	}
	curl_setopt($curl_connection, CURLOPT_URL, $url);
    
       // TODO: refactor these three conditions into one, that accepts the RESTful request type!!
       if($use_post){
	    curl_setopt($curl_connection, CURLOPT_POST, true);
	    curl_setopt($curl_connection, CURLOPT_POSTFIELDS, $params);
	}
        if($use_delete){
	    curl_setopt($curl_connection, CURLOPT_CUSTOMREQUEST, "DELETE");
	}
        if($use_put){
	    curl_setopt($curl_connection, CURLOPT_CUSTOMREQUEST, "PUT");
	    curl_setopt($curl_connection, CURLOPT_POSTFIELDS, $params);
	}
	//end TODO

	curl_setopt($curl_connection, CURLOPT_RETURNTRANSFER, true);
	$curl_response = curl_exec($curl_connection);
	$curl_response_json = json_decode($curl_response,true);
	curl_close($curl_connection);
	return $curl_response_json;
}

The merchant will be able to find the app in the ‘Apps’ section of their dashboard. Shopify remembers that permission was granted by the merchant.

installed apps dashboard

Returning User Log in

The oAuth grant screen will not show again when the app is selected in the future.  As the installation status returns true, our code will flow into the reAuthenticate() method. The same validation checks are performed and a new access token is received.

   
public function reAuthenticate(){
    $conn = $this->conn; 
    $requestData = $this->requestData;
    $requiredKeys = ['code', 'hmac', 'state', 'shop'];
    foreach ($requiredKeys as $required) {
        if (!in_array($required, array_keys($requestData))) {
            throw new Exception("The provided request data is missing one of the following keys: " . implode(', ', $requiredKeys));
            // return;
        }
    }

    //lookup and validate nonce
    $shop = $requestData['shop'];
    
    $statement = $conn->prepare("SELECT * FROM `shopify_authorization_redirect` WHERE shop = :shop");
    $statement->execute(['shop' => $shop]);
    $count = $statement->rowCount();
    if($count == 0){
        throw new Exception("Nonce not found for this shop.");
    }
    $row = $statement->fetch();
    $nonce = $row['nonce'];
    //
    
    //make sure the 'state' parameter provided matches the stored nonce
    $state = $requestData['state'];
    if($state !== $nonce){
        throw new Exception("Nonce does not match provided state.");
    }
    //
    
    //validate the shop name
    $pattern = "/[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com[\/]?/";
    if(!preg_match($pattern, $shop)) {
        throw new Exception("The shop name is an invalid Shopify hostname.");
    }

    //exchange the access code for an access token by sending a request to the shop’s access_token endpoint
    $code = $requestData['code'];
    $post_url = "https://" . $shop . "/admin/oauth/access_token";
    
    $params = [
        'client_id'    => $this->api_key,
        'client_secret'    => $this->secret,
        'code'    => $code
    ];

    $curl_response_json = $this->curlApiUrl($post_url, $params);
    $access_token = $curl_response_json['access_token'];
    
    $statement = $conn->prepare("UPDATE `shopify_installation_complete` SET access_token = ? WHERE shop = ?");
    $statement->execute(array($access_token, $shop));

    header('Location: ' . "/home?shop=".$shop);
}

The merchant is routed to the app’s /home location. A few session variables are set and the user interface is loaded.

<?php
require '/var/www/html/service-layer/shopify-app-service.php';
use SplitWit\ShopifyService\ShopifyService;
$shopify_service = new ShopifyService();
include '/var/www/html/head.php'; 
?>
<style>
	.back-to-projects{
		display: none;
	}   
</style>

<body class="dashboard-body">
    <?php 
    // log the user out...
    $sess_service = new UserService();
    $sess_service -> logout();
    //logout destroys the session. make sure to start a new one.
    if (session_status() == PHP_SESSION_NONE) {
        session_start();
    }
    // ...then log them in
    $already_installed = $shopify_service->checkInstallationStatus();
    $shopify_service->makeSureRecordsExist($already_installed);
    $projectid = $shopify_service->splitwit_project_id; 
    $accountid = $shopify_service->splitwit_account_id; 
    $_SESSION['accountid'] = $accountid;
    $_SESSION['userid'] =  $accountid;
    $_SESSION['email'] = $already_installed['associated_user_email'];
    
    $sess_service -> login();
    $_SESSION['active'] = true;
    include '/var/www/html/includes/experiments-ui.php'; 
    
    ?>
</body>
</html>

The method makeSureRecordsExist() checks that the SplitWit user account and project records exist, as a failsafe. The .back-to-projects CTA is hidden because Shopify users only have access to one project for their shop. The app is installed for free, while premium functionality requires a subscription after a one-week trial.

free trial UI in shopify

When building my second Shopify app, I started with an empty home screen UI:

<?php
require '/var/www/html/service-layer/shopify-app-service.php';
include 'shopify-creds.php';
use SplitWit\ShopifyService\ShopifyService;
$shopify_service = new ShopifyService($api_key, $secret, $app_db);
?>
<html>
<head>
</head>
<body>
   
</body>
<script>


    if(self!==top){
        // if loaded outside of an iframe, redirect away to a marketing page
        window.location = "https://www.splitwit.com";
    }


</script>
</html>

Subscription Payment

SplitWit’s codebase is originally used as a non-Shopify, stand-alone, web app SAAS. It uses Stripe as a payment gateway. Shopify requires apps to use their Billing API instead. To remedy this, I’m able to write Shopify specific front-end code with a simple JavaScript check. I leverage the browser’s window.self property to check if my app’s code is running in the top most window (opposed to being nested in an iFrame).

if(self!==top){
	// shopify app
	$(".activate-cta").remove();
	if(window.pastDueStatus || window.customerId.length === 0){
		$(".activate-loading").show();

		$.ajax({
			url:"/service-layer/shopify-app-service?method=createRecurringApplicationCharge",
			complete: function(response){
				console.log(response)
				//user is redirected to shopify confirmation screen
				$(".activate-loading").hide();
				$(".activate-cta-top").attr("href", response.responseText).show();
			}
		});
	}
	 
	$(".back-cta").click(function(){
		window.history.back();
	})
	$(".reset-pw-cta").hide()

}else{

	$(".activate-cta").click(function(){
		$(".stripe-payment-modal").show();
	});
	
	$(".back-cta").hide();
}

If it’s not the top most window, I assume the code is running in Shopify. I’ll change the click-event listener on the .activate-cta element to create a recurring subscription charge. An AJAX call is made to our PHP end-point that hits Shopify’s RecurringApplicationCharge API.

public function createRecurringApplicationCharge(){
	
	$conn = $this->conn;
	$statement = $conn->prepare("SELECT * FROM `shopify_installation_complete` WHERE splitwit_account_id = :splitwit_account_id");
	$statement->execute(['splitwit_account_id' => $_SESSION['accountid']]);
	$row = $statement->fetch();
	$shop = $row['shop'];
	$access_token = $row['access_token'];
	
	$create_recurring_charge_url = "https://" . $this->api_key . ":" . $this->secret . "@" . $shop . "/admin/api/2020-04/recurring_application_charges.json";
	$params = [
        'recurring_application_charge' => [
            'name' => 'Basic Plan',
            'price' => 25.0,
            // 'return_url' => "https://" . $shop . "/admin/apps/splitwit",
            // 'test' => true,
            'return_url' => "https://www.splitwit.com/service-layer/shopify-app-service?method=confirmSubscription"
        ]
	];
	$headers = array(
		'X-Shopify-Access-Token: ' . $access_token,
	 	'content-type: application/json'
	);
	$json_string_params = json_encode($params);

	$create_recurring_charge_curl_response_json = $this->curlApiUrl($create_recurring_charge_url, $json_string_params, $headers);
	echo $create_recurring_charge_curl_response_json['recurring_application_charge']['confirmation_url'];
}

The charge ID (delivered by the Shopify request to our ‘return_url’), payment processor, and subscription expiry date are saved to our database on call-back before returning a location header to reload the app.

public function confirmSubscription(){
	
	$conn = $this->conn;
	$statement = $conn->prepare("SELECT * FROM `shopify_installation_complete` WHERE splitwit_account_id = :splitwit_account_id");
	$statement->execute(['splitwit_account_id' => $_SESSION['accountid']]);
	$row = $statement->fetch();
	$shop = $row['shop'];
 
 	$charge_id = $_REQUEST['charge_id'];
	//write shopify billing ID to db
	$sql = "UPDATE `account` SET payment_processor = ?, billing_customer_id = ?, current_period_end = ?, past_due = 0 WHERE accountid = ?"; 
	$result = $conn->prepare($sql); 
	$current_period_end = new \DateTime();  //we need the slash here (before DateTime class name), since we're in a different namespace (declared at the top of this file)
	$current_period_end->modify( '+32 day' );
	$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 
	$payment_processor = "shopify";
	$result->execute(array($payment_processor, $charge_id, $current_period_end, $_SESSION['accountid']));
	
	//redirect to app
	header('Location: ' . "https://" . $shop . "/admin/apps/splitwit");

}

That charge ID (saved to our database in a column titled “billing_customer_id”) can later be passed back to Shopify to delete the recurring charge.

Cancel a Subscription

Once a subscription is active, I can check  the payment processor saved the the account’s DB record to toggle the “cancel account” functionality from Stripe to Shopify.

<?php if ($account_row['payment_processor'] == "shopify"){ ?>
	//hit shopify service

	$(".cancel-cta").click(function(){
		//
		$.ajax({
			url:"/service-layer/shopify-app-service?method=cancelSubscription",
			complete: function(response){
				window.location.reload();
			}
		});
	});

<?php }else{ ?>
	//hit the stripe service
	
	$(".cancel-cta").click(function(){
		$(".cancel-subscription-modal").show();
	});

<?php }?>

The cancelSubscription method hits the same Shopify recurring_application_charges API, but uses a DELETE request. It also deletes the Shopify billing ID from our records.

public function cancelSubscription(){
	
	$conn = $this->conn;
	$statement = $conn->prepare("SELECT * FROM `shopify_installation_complete` WHERE splitwit_account_id = :splitwit_account_id");
	$statement->execute(['splitwit_account_id' => $_SESSION['accountid']]);
	$row = $statement->fetch();
	$shop = $row['shop'];
	$access_token = $row['access_token'];

	$statement = $conn->prepare("SELECT * FROM `account` WHERE accountid = :accountid");
	$statement->execute(['accountid' => $_SESSION['accountid']]);
	$account_row = $statement->fetch();
	$charge_id = $account_row['billing_customer_id'];


	$delete_recurring_charge_url = "https://" . $this->api_key . ":" . $this->secret . "@" . $shop . "/admin/api/2020-04/recurring_application_charges/#" . $charge_id . ".json";

	$params = [];
	$headers = array(
		'X-Shopify-Access-Token: ' . $access_token,
	 	'content-type: application/json'
	);
	$json_string_params = json_encode($params);
	$delete = true;

	$delete_recurring_charge_curl_response_json = $this->curlApiUrl($delete_recurring_charge_url, $json_string_params, $headers, $delete);

	//delete shopify billing ID from db
	$empty_string = "";
	$sql = "UPDATE `account` SET payment_processor = ?, billing_customer_id = ? WHERE accountid = ?"; 
	$result = $conn->prepare($sql); 
	$result->execute(array($empty_string, $empty_string, $_SESSION['accountid']));
	
	echo $delete_recurring_charge_curl_response_json;


}

I can use these same recurring application API end-point functions with minimal adjustments for other Shopify apps that I build. After refactoring, I am able to specify an app database as a GET parameter in the AJAX calls to my Shopify PHP service.

Uninstall the App

delete shopify app

Merchants can choose to delete apps from their shop. This will remove it from their list of installed apps. If they try installing it again, they will be re-promoted for permissions. When an app is deleted, a webhook is notified so that code can handle server-side uninstall logic:

The payment processor and billing ID associated with the merchant’s account is set to an empty string. The ‘shopify_installation_complete’ shop record is deleted.

public function uninstallApplication(){
	$conn = $this->conn; 
	
	$res = '';
	$hmac_header = $_SERVER['HTTP_X_SHOPIFY_HMAC_SHA256'];
	$topic_header = $_SERVER['HTTP_X_SHOPIFY_TOPIC'];
	$shop_header = $_SERVER['HTTP_X_SHOPIFY_SHOP_DOMAIN'];
	$data = file_get_contents('php://input'); //similar to $_POST
	$decoded_data = json_decode($data, true);
	$verified = $this->verifyWebhook($data, $hmac_header);

	if( $verified == true ) {
	  if( $topic_header == 'app/uninstalled' || $topic_header == 'shop/update') {
	    if( $topic_header == 'app/uninstalled' ) {
			$domain = $decoded_data['domain'];

			$statement1 = $conn->prepare("SELECT * FROM `shopify_installation_complete` WHERE shop = ?");
			$statement1->execute(array($domain));
			$row = $statement1->fetch();
			$accountid = $row['splitwit_account_id'];

			//delete shopify billing ID from db
			$empty_string = "";
			$result = $conn->prepare("UPDATE `account` SET payment_processor = ?, billing_customer_id = ? WHERE accountid = ?"); 
			$result->execute(array($empty_string, $empty_string, $accountid));

			$statement = $conn->prepare("DELETE FROM `shopify_installation_complete` WHERE shop = ?");
			$statement->execute(array($domain));

	    } else {
	      $res = $data;
	    }
	  }
	} else {
	  $res = 'The request is not from Shopify';
	}

}

Any webhook requests have the HMAC delivered as a header (instead of a query param, as in the case of oAuth requests) and is processed differently. “The HMAC verification procedure for OAuth is different from the procedure for verifying webhooks“. The method verifyWebhook() takes care of it:

public function verifyWebhook($data, $hmac_header){
  $calculated_hmac = base64_encode(hash_hmac('sha256', $data, $this->secret, true));
  return hash_equals($hmac_header, $calculated_hmac);
}

Cache Busting

When project changes are recorded in the app, the merchant’s snippet file is updated. We need to be sure that their website recognizes the latest version. In a separate class (that handles project & snippet logic) I make a HTTP request to my method that re-writes the script tag.

public function updateSnippetScriptTag(){
	$projectid = $_GET['projectid'];
	$conn = $this->conn;
	$sql = "SELECT * FROM `shopify_installation_complete` WHERE splitwit_project_id = ?"; 
	$result = $conn->prepare($sql); 
	$result->execute(array($projectid));
	$row = $result->fetch(\PDO::FETCH_ASSOC);
	$number_of_rows = $result->rowCount();
	if($number_of_rows == 1){
		$access_token = $row['access_token'];
		$shop = $row['shop'];
		$sql = "SELECT * FROM `project` WHERE projectid = ?"; 
		$project_result = $conn->prepare($sql); 
		$project_result->execute(array($projectid));
		$project_row = $project_result->fetch(\PDO::FETCH_ASSOC);
		$snippet = $project_row['snippet'];			

		$script_tag_url = "https://" . $this->api_key . ":" . $this->secret . "@" . $shop . "/admin/api/2020-04/script_tags.json";
		$headers = array(
		  'X-Shopify-Access-Token:' . $access_token,
		  'content-type: application/json'
		);
		$params = [];
		$json_string_params = json_encode($params);
		$use_post = false;
		//get existing script tag
		$get_script_curl_response_json = $this->curlApiUrl($script_tag_url, $json_string_params, $headers, $use_post);
		$tags = $get_script_curl_response_json['script_tags'];
	
		foreach ($tags as $tag) {
			$id = $tag['id'];
			$delete_script_tag_url = "https://" . $this->api_key . ":" . $this->secret . "@" . $shop . "/admin/api/2020-04/script_tags/" . $id . ".json";
			$use_delete = true;
			$delete_script_curl_response_json = $this->curlApiUrl($delete_script_tag_url, $json_string_params, $headers, $use_post, $use_delete);
		}
		 
		//add snippet
		$snippet = "https://www.splitwit.com/snippet/" . $snippet . "?t=" . time();
		$params = [
			'script_tag' => [
				'event' => 'onload',
				'src' => $snippet 
			]
		];
		$json_string_params = json_encode($params);
		$create_script_curl_response_json = $this->curlApiUrl($script_tag_url, $json_string_params, $headers);	 

	}
}

Once our Shopify app is built and tested we can begin to prepare for submission to the Shopify App Market.

Preparing for production

Shopify allows you to test your app on a development store.

test your app

After debugging your code locally, make sure it works end-to-end in Shopify’s environment.

test your app on Shopify

Even though the app is “unlisted”, and has not yet been accepted into the Shopify App Market, you’ll still be able to work through the entire UX flow.

install an unlisted app

GDPR mandatory webhooks

Each app developer is responsible for making sure that the apps they build for the Shopify platform are GDPR compliant.” Every app is required to provide three webhook end-points to help manage the data it collects. These end-points make requests to to view stored customer data, delete customer data, and delete shop data.  After handling the request, an HTTP status of 200/OK should be returned. PHP lets us do that with its header() function:

header("HTTP/1.1 200 OK");

These GDPR webhook subscriptions can be managed on the “App setup” page.

gdpr webhook settings

App Listing

Before submitting your app to the Shopify App Market, you’ll need to complete “Listing Information”. This section includes the app’s name, icon, description, pricing details, and more. It is encouraged to include screenshots and a demonstration video. Detailed app review instructions, along with screenshots and any on-boarding information, will help move the approval process along more quickly.

app review instructions in the app listing section of Shopify

Approval Process

Complete the setup and listing sections, and submit your app.

shopify app listing issues

You’ll receive an email letting you know that testing will begin shortly.

email from shopify

You may be required to make updates based on feedback from Shopify’s review process. After making any required changes, your application will be listed on the Shopify App Store. Below is an example of feedback that I had received:

Required changes from Shopify's app review process

To remedy the first required change I added additional onboarding copy to the app’s listing and included a demonstration YouTube video.

The second point was fixed by stopping any links from opening in new tabs. (Although, the reviewer’s note about ad blocking software stopping new tabs from opening is bogus).

The third issue was resolved by making sure the graphic assets detailed in my app listing were consistent.

Soon after making these changes, my app was finally approved and listed.

Keep Building

While writing this article I extended and refactored my PHP code to support multiple apps. I added configuration files to keep database settings modular. The Shopify PHP class can serve as back-end to several implementations. If you have any questions about how to build a Shopify app, or need my help, send me a message.

Update:

I wrote a subsequent post about building another Shopify app. It’s called SplitWit Click to Call. It explains the creative details that go into shipping a fulling working SAAS. I dive into new features that are only available to Shopify themes running the latest OS2.0 experience.

Additional References

https://github.com/LukeTowers/php-shopify-api

https://weeklyhow.com/shopify-uninstall-webhook-api-tutorial

https://github.com/markrogoyski/awesome-php

 

 

Custom UI notifications

UI feedback alerts

Showing brief notifications to website visitors is an important UI/UX component. They’re useful for providing feedback. They can communicate success, failure, or warnings.

Don Norman (The Design of Everyday Things) mentions that “Feedback is essential, but not when it gets in the way of other things, including a calm and relaxing environment” and goes on to say “Feedback is essential, but it has to be done correctly”.

A common use-case is data validation. Specifically, when logging in or signing up. If the user enters an invalid email address, or wrong login credentials, we need to let them know. The built in browser alert() is clunky and unsophisticated. Plugins are bloated and over-engineered. I wrote some basic HTML, CSS, and JavaScript that gets the job done and looks great.

My code provides two versions of the alert. The first is a basic sticky bar that fades in and out at the top of the page.

example of alert message for an invalid email address

The other flashes in the middle of the screen. I call it “in-your-face” alerts and reserve them for positive success messages.

example of a flashing UI alert to provide positive feedback to users

The CSS adds styles for both versions. Both utilize ‘position: fixed’ to stay in a set location on the page. The “in-your-face” example uses a pulse animation to achieve its effect.

<!-- UI-notifications.css -->
<style>
body{
  margin: 0px;
}
.status-message{
  display: none;
  color: white;
  text-align: center;
  font-size: 16px;
  padding: 8px;
  border-top: 1px solid white;
  border-bottom: 1px solid white;
  position: fixed;
  width: 100%;
  top: 0px;
  padding: 28px 8px;
  background-color: #b12650;
  z-index: 1000;
}
.status-message-inner{
  margin: 0px;
}

.status-message-close{
  cursor: pointer;
  position: fixed;
  right: 10px;
}
.in-your-face{
  display: none;
  position: fixed;
  top: 45%;
  width: 100%;
  text-align: center;
  font-size: 48px;
  color: white;
  z-index: 2;
}
.in-your-face-inner{
    background: #005b96;
    width: 80%;
    margin: 0 auto;
    opacity: .85;
    padding: 10px;
}
@keyframes pulse{
  50%  {transform: scale(1.2);}

}
.pulse{
  animation: pulse 0.5s ease-in infinite;
}
</style>
<!-- end UI-notifications.css -->

The javascript relies on jQuery as a dependency. It is written as a class, with a constructor and two methods. Each method takes message text as a parameter.

class UINotifications {
	constructor() {
		window.jQuery || document.write('<script src="js/vendor/jquery-1.11.2.min.js"><\/script>');
		var statusMessageHtml = '<div class="status-message"><p class="status-message-inner"><span class="status-message-text">Welcome to My App</span><span class="status-message-close">X</span></p></div>';
		var inYourFaceHtml = '<div class="in-your-face pulse"><p class="in-your-face-inner"><span class="in-your-face-text">Great Job!</span></p></div>';

		$(document).on("click", ".status-message-close", function(){
			$(".status-message").fadeOut();
		});

		this.statusMessage = $("<div/>").html(statusMessageHtml);
		this.inYourFace = $("<div/>").html(inYourFaceHtml);
		
		$('body').prepend(this.inYourFace);
		$('body').prepend(this.statusMessage);

	}

 	showStatusMessage(message){
 		var notifications = this;
	  	var message = message || "Default Message"
	  	var statusMessageTimeout;
	  	
		if(notifications.statusMessage.find(".status-message").is(':visible')){
	     clearTimeout(statusMessageTimeout);
	    }

		notifications.statusMessage.find(".status-message .status-message-text").html(message);
		notifications.statusMessage.find(".status-message").fadeIn();
		
	    statusMessageTimeout = setTimeout(function(){
	       notifications.statusMessage.find(".status-message").fadeOut(); 
	    }, 5000)
		
	}
	showInYourFace(message, callback){
		var notifications = this;
		var inYourFaceTimeout;
		var inYourFaceRandoms = ["Good work!", "Hard work!", "Nice job!", "Hustle!"]

		var message = message || inYourFaceRandoms[Math.floor(Math.random()*inYourFaceRandoms.length)];;
		var callback = callback || function(){};

		if(notifications.inYourFace.find(".in-your-face").is(':visible')){
	     clearTimeout(inYourFaceTimeout);
	    }

		notifications.inYourFace.find(".in-your-face .in-your-face-text").html(message);
		notifications.inYourFace.find(".in-your-face").show();
		
	    inYourFaceTimeout = setTimeout(function(){
	       notifications.inYourFace.find(".in-your-face").fadeOut(function(){
	       	callback();
	       }); 

	    }, 1000)
	}
}

This is a simple and lightweight solution to showing web app visitors informative alerts without using a plugin. Please, checkout the code and use it in your next project.

You can find the code on GitHub.

Lazy Load Images and Assets on WordPress with IntersectionObserver

wordpress homepage design

I write online a lot. Adding articles to this blog serves to build a catalog of technical solutions for future reference. Updating the homepage has improved user experience and SEO. The new design displays the most recent articles as clickable cards, rather than listing the entire text of each one. The changes for this were added to index.php file, in the child-theme folder. The theme’s original code already used a While() loop to iterate through the post records. My modification removed the article content, and only kept the title and image:

<div class="doc-item-wrap">
	<?php
	while ( have_posts() ) {
		the_post();
		echo "<div class='doc-item'><a href='". get_the_permalink() ."'><img class='lazy' data-src='".get_the_post_thumbnail_url()."'><h2>" . get_the_title() . "</h2></a></div>";
	} ?>
</div> <!-- doc-item-wrap -->

I used custom CSS, leveraging Flexbox, to style and position the cards:

.doc-item-wrap{
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
}
.doc-item{
    width: 30%;
    padding: 20px;
    border: 3px solid #f0503a;
    margin: 15px;
    background: black;
    flex-grow: 1;
    text-align: center;
}
.doc-item:hover{
    background-color: #34495e;
}
.doc-item p{
    margin: 0px;
    line-height: 40px;
    color: white;
}
.doc-item img{
    display: block;
    margin: 0 auto;
}
.doc-item h2{
    font-size: 22px;
    color: white;

}
@media(max-width: 1000px){
	.doc-item{
		width: 45%
	}
}
@media(max-width: 700px){
	.doc-item{
		width: 100%
	}
}

The media queries adjust the size of the cards (and how many are in a row), based on screen size.

Look and feel of the design

Card layout design is a common way to arrange blog content. It gives visitors a visual overview of what’s available. It also stops the homepage from duplicating content that’s already available on the individual post pages.

You can see this pattern throughout the digital world. Card layout translates well across screen sizes and devices. Since I put much effort into writing, making it organized was a priority. This implementation can be extended to add additional content (such as date, description, etc.) and features (share links, animations, expandability). And, it fits nicely with what WordPress already provides.

Lazy loaded images

Image content can often be the biggest drag to site speed. Lazy loading media defers rendering until it is needed. Since this blog’s homepage has an image for each post, this was essential.

While iterating through post records the image URL is assigned to a custom data-src attribute on the image tag, leaving the normal src blank. This assures the image is not immediately retrieved nor loaded. I wrote a JavaScript function to lazy load the images, relying on the IntersectionObserver API. The card’s image does not load until a user scrolls it into view. This improves the speed of the page, which has a positive effect on SEO and UX.

The code creates a IntersectionObserver object.  It observes each of the image elements, checking to see if they are within the browser viewport. Once the image elements come into view, it takes the image URL from the data-src attribute, and assigns it to the tag’s src – causing the image to load.

document.addEventListener("DOMContentLoaded", function() {
  var lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));
  if ("IntersectionObserver" in window) {
    let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
      entries.forEach(function(entry) {
        if (entry.isIntersecting) {
          let lazyImage = entry.target;
          lazyImage.src = lazyImage.dataset.src;
          // lazyImage.srcset = lazyImage.dataset.srcset;
          lazyImage.classList.remove("lazy");
          lazyImageObserver.unobserve(lazyImage);
        }
      });
    });

    lazyImages.forEach(function(lazyImage) {
      lazyImageObserver.observe(lazyImage);
    });
  } 
});

The original JS code was referenced from a web.dev article. Web.dev is a resource created by Google that provides guidance, best practices, and tools to help web developers build better web experiences.

You can also use this same method for lazy loading videos, backgrounds, and other assets.

IntersectionObserver to lazily load JavaScript assets

I discovered another application for the IntersectionObserver implementation that I used above to load lazy load images. The Google Lighthouse performance score on my homepage was being dinged due to the “impact of third-party code”.

impact of 3rd party js on performance scores

The largest third-party resource was is used for the reCaptcha on a contact form at the bottom of my homepage. It makes sense to not load it until the user scrolls down to that section – especially because the UX expects them to take time to fill out the form before hitting “submit” anyway.

Using the same pattern as above, I created a new `IntersectionObserver` and passed the contact form section as the target:

function captchaLazyLoad(){
	contactCaptchaTarget = document.getElementById('contactSection')
	if (!contactCaptchaTarget) {
        return;
    }
	let contactCaptchaObserver = new IntersectionObserver(function(entries, observer) {
		if (entries[0].isIntersecting) {
            var script = document.createElement('script');
		    script.src = "https://www.google.com/recaptcha/api.js";
		    document.body.appendChild(script);
            contactCaptchaObserver.unobserve(contactCaptchaTarget);
        }
	})
	contactCaptchaObserver.observe(contactCaptchaTarget);
}

I included this function to the already existing `DOMContentLoaded`  event listener just below the loop to observe lazy image elements:

<script>
function captchaLazyLoad(){
	contactCaptchaTarget = document.getElementById('contactSection')
	if (!contactCaptchaTarget) {
        return;
    }
	let contactCaptchaObserver = new IntersectionObserver(function(entries, observer) {
		if (entries[0].isIntersecting) {
            var script = document.createElement('script');
		    script.src = "https://www.google.com/recaptcha/api.js";
		    document.body.appendChild(script);
            contactCaptchaObserver.unobserve(contactCaptchaTarget);
        }
	})
	contactCaptchaObserver.observe(contactCaptchaTarget);
}

document.addEventListener("DOMContentLoaded", function() {
  var lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));
  if ("IntersectionObserver" in window) {
    let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
      entries.forEach(function(entry) {
        if (entry.isIntersecting) {
          let lazyImage = entry.target;
          lazyImage.src = lazyImage.dataset.src;
          // lazyImage.srcset = lazyImage.dataset.srcset;
          lazyImage.classList.remove("lazy");
          lazyImageObserver.unobserve(lazyImage);
        }
      });
    });

    lazyImages.forEach(function(lazyImage) {
      lazyImageObserver.observe(lazyImage);
    });

    //
    captchaLazyLoad()

  } else {
    // Possibly fall back to a more compatible method here if IntersectionObserver does not exist on the browser
  }

});

</script>

This update raised my Lighthouse performance score by fifteen points!