Widget-Building in Kibo Ecommerce Themes

December 14, 2020
 

Kibo Ecommerce themes give you complete control over your storefront’s frontend experience. With our Kibo commerce themes, you can design snazzy home pages, tailor product and category pages, and create specialized widgets that allow merchants to add functionality to pages in our Site Builder.

Here I’ll present a couple examples showing what you may do with a custom widget—to help assist you in whatever scenario comes up, no matter how big or small.

(In the process of developing and extending the core theme to fit the requests for demonstration, you get used to what the pieces represent – for general widget information and documentation, you can read additional details in our documentation.)

For our first example, we’ll keep it basic: a small global `<style>` block output on the global page template. This won’t create a drag-and-drop widget utilized by the sidebar, but will exist in the page output instead. Our second example will showcase integrating a third-party script tag in Kibo’s Monetate but is easy enough to configure and adjust for any third-party script and use case.

CSS Block Widget on Global Page Template

In this scenario, we’ll have two main widget components to create or edit: (1) an admin UI update for the user, and then (2) the output into the template. As this is a direct output, we don’t need to associate JavaScript or CSS.

Admin UI Changes:

In order to have our settings accessible to the global themeSettings object we want to make sure the variables are declared first, and then create our Business User’s UI panel to allow them to make changes directly without needing developer assistance (it’s a huge trust exercise, I know).

Note: I’m using double-slash code comments, which is invalid JSON

We’ll add the settings reference inside our theme.json file:

{
	“settings”: {
		// within the settings object, we’ll include the following key: value pairs
		"useGlobalOverride": false, // this is a simple toggle to show/hide the field
		"globalCSSOverride": ""
	}
}	

And we will add our custom UI Panel for the admin inside our theme-ui.json file:

You’ll insert the object in the order you would like the navigation menu tab to appear.

{
	"items": [
		// within the items array, we’ll include the following object
		{
			"xtype": "panel",
			"title": "Global CSS Override",
			"collapsed": false,
			"items": [
				{
					"xtype": "mz-input-checkbox",
					"name": "useGlobalOverride",
					"fieldLabel": "Use Global CSS Style Element?"
				},
				{
					"xtype": "mz-input-textarea",
					"name": "globalCSSOverride",
					"fieldLabel": "CSS to be applied GLOBALLY",
					"showIf": "useGlobalOverride"
				}
			]
		}
	]
}

The above outputs a simple field output—as our default value  for “useGlobalOverride” is false, I’ve toggled it for our screenshot so you can see the CSS entry “block.” The main reason is to serve as a safeguard, so it has to be enabled to be used. This prevents it from having it output all the time, impeding accidental CSS code living in a template without realizing you it.

Global CSS Override Admin UI Component Output

Template Changes:

We’ll create a new hypr file for our code inside of templates/Widgets/global-css-override.hypr:

Questions on Hypr tags? Our docs will have more insights.

{% comment %}
We can reference the `themeSettings` object to check the feature is enabled (`useGlobalOverride`), and that there is an output (`globalCSSOverride`)
{% endcomment %}
{% if themeSettings.useGlobalOverride && themeSettings.globalCSSOverride %}
<!-- GLOBAL CSS OVERRIDE FROM THEME -->
<style>
{{ themeSettings.globalCSSOverride }}
</style>
{% endif %}

As we want this CSS to be inline and override the loaded CSS files, we can directly modify the main templates/page.hypr file:

<head>
	// ...preexisting header code above

	// our new widget reference
	{% include "Widgets/misc/global-css-override" %}
</head>

Finally:

…and that’s all there is to it to get the style block output. Simple and straightforward when you need something I would call “quick and dirty” in terms of throwing down a fast widget.

Third-Party Script Tag in Kibo’s Monetate

One of the first “widgets” I wrote was to add Monetate, part of the Kibo Personalization solution, tagging to a theme. Because of the flexibility of the templating language, you really do have several ways to accomplish this. This represents just one way, and is a similar method you can modify for use if you need a reusable third-party widget you’d want to empower your business users to configure.

Additional information on Kibo’s Monetate is available on the Monetate Developer’s Hub (a login is required).

We still have the same files we need to update for Admin access (theme.json and theme-ui.json), but we will also create a new JavaScript file and a couple CSS files (we’re utilizing LESS as a CSS preprocessor, so you’ll see that file extension).

Admin UI Changes:

We’ll add the settings reference inside our theme.json file:

{
	“settings”: {
		// within the settings object, we’ll include the following key: value pairs
		"loadMonetateScript": false,
		"loadMonetateAsynchronous": false,
		"monetateScriptUrl": ""
	},
	"widgets": [
		// within the widgets array, we’ll include the following objects
		// the first widget object represents our monetate observation script to add in the additional tracking, if you’d like it to be “per page” or globally, as shown in the code later
		{
			"displayName": "Monetate Observation",
			"displayTemplate": "monetate/monetate-observation.hypr",
			"icon": "/resources/admin/widgets/monetate-logo.png",
			"id": "monetate-observation",
			"validPageTypes": [
				"*"
			]
		},
		// this widget object represents a custom output `<div>` tag
		// this exists purely as an example output, as Monetate allows you with a lot of custom targeting capabilities
		// it will allow you to create a div with a specific ID and drag and drop it on the page, so your monetate implementation can target it via `ID`
		{
			"defaultConfig": {
				"title": "Monetate Default Title"
			},
			"displayName": "Monetate Recommended Products",
			"displayTemplate": "monetate/monetate-recs.hypr",
			"editViewFields": [
				{
					"fieldLabel": "Container name (lowercase, -, _)",
					"name": "monetateContainerName",
					"anchor": "100%",
					"xtype": "mz-input-text"
				},
				{
					"fieldLabel": "Widget Display Title (leave blank to default to name configured in Monetate)",
					"name": "monetateWidgetTitle",
					"anchor": "100%",
					"xtype": "mz-input-text"
				}
			],
			"icon": "/resources/admin/widgets/monetate-logo.png",
			"id": "monetate-recs",
			"validPageTypes": [
				"*"
			]
		}
	]
}	

And our custom UI Panel for the admin inside our theme-ui.json file:

You’ll again insert the object in the order you would like the navigation menu tab to appear.

{
	"items": [
		// within the items array, we’ll include the following object
		{
			"xtype": "panel",
			"title": "Monetate",
			"collapsed": false,
			"anchor": "100%",
			"items": [
				{
					"xtype": "mz-input-checkbox",
					"name": "loadMonetateScript",
					"fieldLabel": "Load Monetate script on all pages"
				},
				{
					"xtype": "mz-input-text",
					"name": "monetateScriptUrl",
					"fieldLabel": "Monetate Script URL (without the prefixed `se.` or `e.`)",
					"enableIf": "loadMonetateScript",
					"width": 600
				},
				{
					"xtype": "fieldcontainer",
					"defaultType": "radiofield",
					"defaults": {
						"flex": 1
					},
					"enableIf": "loadMonetateScript",
					"fieldLabel": "Load Monetate Asynchronously",
					"layout": "vbox",
					"items": [
						{
							"boxLabel": "Asynchronous (non-blocking)",
							"name": "loadMonetateAsynchronous",
							"inputValue": true
						},
						{
							"boxLabel": "Synchronous (loads in the &lt;head&gt; tag)",
							"name": "loadMonetateAsynchronous",
							"inputValue": false,
							"checked": true
						}
					]
				}
			]
		}
	]
}

And our Admin UI output:

ecommerce themes templates
Monetate Admin UI Component Output

And our widgets panel output (and shows the importance of short names of your widgets):

kibo ecommerce themes
Monetate Widget Sidebar Output

This step is fairly straightforward, as most of the heavy lifting will be handled via our JavaScript and Hypr templates.

Template Changes:

We’ll create a handful of new  files for our new code, and modify two main template files to utilize them:

New files:

  • templates/modules/monetate/script.hypr // where the magic happens
  • templates/Widgets/monetate/monetate-observation.hypr // the monetate observation script, or where we will grab and send data to monetate
  • templates/Widgets/monetate/monetate-recs.hypr // the simple “holder” for a monetate target (for example use)
  • scripts/widgets/monetate/monetate-observation.js // a helper file for the “drop-in” widget
  • stylesheets/widgets/monetate-default.less // default monetate styling
  • stylesheets/widgets/monetate.less // an override file I made to avoid touching the above default styling

Existing files to modify:

  • templates/page.hypr
  • templates/modules/trailing-scripts.hypr

First up, templates/modules/monetate/script.hypr – a script holder for loading in Monetate (slightly modified):

// this file allows us to use the pageContext object to help output the correct URL structure for monetate
{% if themeSettings.loadMonetateScript && !themeSettings.loadMonetateAsynchronous %}
<!-- Begin Monetate ExpressTag Sync v8.1. Place at start of document head. DO NOT ALTER. -->
<script type="text/javascript">
var monetateT = new Date().getTime();
window.monetateQ = window.monetateQ || [];
window.monetateQ.push([ "setPageType", "" ]);
</script>
<script type="text/javascript" src="//{% if pageContext.isSecure %}s{% endif %}e.{{ themeSettings.monetateScriptUrl }}"></script>
<!-- End Monetate tag. -->
{% endif %}

templates/Widgets/monetate/monetate-observation.hypr – the monetate observation script, or where we will grab and send data to Monetate:

// the monetate observation script, or where we will grab and send data to monetate
{% comment %}
If we are in the site editor, this will output a message to the business user if they drag and drop the widget on the page and it either:
a) has already been declared to load globally
B ) otherwise is output on the page in one-off scenarios
{% end comment %}
{% if pageContext.isEditMode == 'true' %}
    {% if themeSettings.loadMonetateScript && !themeSettings.loadMonetateAsynchronous %}
        <div>Monetate Observation is already loaded in &lt;head&gt;.</div>
    {% else %}
        <div>Monetate Observation on page.</div>
    {% endif %}
{% endif %}


{% if themeSettings.includeCategoryHierarchy and pageContext.pageType == "category" %}
    {% preload_json navigation.breadcrumbs "breadcrumbs" %}
{% endif %}

{% require_script "widgets/monetate/monetate-observation" %}

templates/Widgets/monetate/monetate-recs.hypr – the simple “holder” for a monetate target (for example use):

{% comment %}
This is much more simple, and merely has the ability to be configured with additional data, targeting ID and/or class names or data attributes
It also includes text output that is visible on the Site Editor screen in admin (and won’t be output on the front-end of the live site)
{% endcomment %}
<div class="monetate-recs {{model.config.monetateContainerName}}" data-mz-config="{% json_attribute Model.config %}" data-reczone="{{model.config.monetateContainerName}}">
{% if pageContext.isEditMode == 'true' %}
Monetate Recommendations area for: {{model.config.monetateContainerName}}
{% endif %}
</div>

scripts/widgets/monetate/monetate-observation.js – where the JavaScript/tagging manipulation logic lives:

// this includes “work in progress” and debug statements not suitable for production, and is included for reference/example
// https://knowledge.monetate.com/hc/en-us/articles/115001250026-The-Monetate-JavaScript-API-Overview-API-Info-
require([
	'hyprlive',
	'modules/jquery-mozu',
	'pages/product'
], function (Hypr, $, product) {
	const loadMonetate = () => {
		console.info('add monetate script')

		window.monetateT = new Date().getTime();
		var p = document.location.protocol;
		if (p == "http:" || p == "https:") {
			var m = document.createElement('script');
			m.type = 'text/javascript';
			m.async = true;
			m.src = (p == "https:" ? "https://s" : "http://") + Hypr.getThemeSetting('monetateScriptUrl');
			var s = document.getElementsByTagName('script')[0];
			s.parentNode.insertBefore(m, s);
		}
	}
	// loads Monetate script if theme setting is true
	if (Hypr.getThemeSetting('loadMonetateScript') && Hypr.getThemeSetting('loadMonetateAsynchronous')) {
		loadMonetate();
	}

	$(function () {
		console.info('monetate observation code loaded')
		window.monetateQ = window.monetateQ || []
		// gets page context
		const context = require.mozuData('pagecontext');
		window.kibo_user = require.mozuData('user');

		// gets product data from order line items to easily get product codes
		const getOrderRows = (order) => order.items.map((item) => {
			const { product, quantity, unitPrice } = item

			return {
				productId: product.productCode,
				quantity: quantity,
				unitPrice: unitPrice.extendedAmount,
				sku: product.productCode,
				currency: order.currencyCode
			}
		})

		// loads same data for both confirmation1 and confirmation2
		const confirmationPageData = function () {
			var order = require.mozuData('checkout');
			window.monetateQ.push(["setPageType", "purchase"])
			window.monetateQ.push([ "addPurchaseRows", getOrderRows(order) ]);
		};

		const productListingPageData = () => {
			const modelTag = document.getElementById('data-mz-preload-items')
			let model = {}
			let products = []

			if (modelTag) {
				model = modelTag.innerHTML.length ? JSON.parse(modelTag.innerHTML.trim().replace(/&quot;/g,'"')) : {}
				products = model.items ? model.items.map(item => item.productCode) : []
			}

			window.monetateQ.push(["addProducts", products])
			window.monetateQ.push(["setPageType", "index"])
		}

		// adds custom fields depending on page type
		switch (context.pageType) {
			case 'product':
				const productModel = require.mozuData('product')
				window.monetateQ.push(["setPageType", 'product'])

				window.monetateQ.push([
					"addProductDetails",
					[
						{
							productId: productModel.productCode,
							sku: productModel.productCode
						}
					]
				]);

				setTimeout(() => {
					let product = window.productView.model;
					product.on('addedtocart', (cartitem) => {
						try {
							const productId = cartitem.data.product.productCode
							const quantity = cartitem.data.quantity
							const sku = cartitem.data.product.productCode
							const unitPrice = cartitem.data.product.price

							window.monetateQ.push([
								"addCartRows",
								[
									{
										productId,
										quantity,
										sku,
										unitPrice
									}
								]
							]);
						} catch (e) {
							console.error(e);
						}
					});

					product.on('addedtowishlist', function (cartitem) {
						// try {
						// } catch (e) {
						// 	console.error(e);
						// }
					});
				}, 300)
				break;

			case 'category':
			case 'search':
				productListingPageData()
				break;
			case 'cart':
				let cart = require.mozuData('cart');
				let cartRows = []

				if (cart.items.length) {
					cartRows = getOrderRows(cart)

					setTimeout(() =>{
						let cart = window.cartView.cartView.model;

						cart.on('removefromcart', function (cartitem) {
							// const product = cartitem.get('product').toJSON();
							// try {
							// } catch (e) {
							// 	console.error(e);
							// }
						});
					}, 300)
				}

				window.monetateQ.push([ "addCartRows", cartRows]);
				window.monetateQ.push(["setPageType", "cart"]);
				break;

				// confirmation and confirmationv2 are handled the same
			case 'confirmation':
			case 'confirmationv2':
				confirmationPageData();
				break;

			case 'my_account':
				window.monetateQ.push(["setPageType", 'account'])
				break;

			default:
				let pageType = 'main'
				// home page doesn't follow same pageType pattern that rest of the templates do, so adjusting for that
				if (context.cmsContext.template.path === 'home') {
					pageType = 'home'
				}
				window.monetateQ.push(["setPageType", pageType])

				// any other static pages can be handled in this default case
				break;
		}

		window.monetateQ.push(["trackData"])
	})
});

stylesheets/widgets/monetate-default.less – the default styling I use for Monetate output:

/**
 * DEFAULT SLIDER STYLES
 * Edit at your own risk!
 */
[id^='mt-slider'] {
    position: relative;
}

[id^='mt-slider'] [data-slider-mask] {
    overflow: hidden !important;
    position: relative !important;
    padding: 0 !important;
    margin: 0 !important;
}

[id^='mt-slider'] li {
    list-style: none;
}

[id^='mt-slider'] [data-slider] {
    padding: 0 !important;
    margin: 0 !important;
    display: block;
}

[id^='mt-slider'] [data-slide] {
    float: none;
}

[id^='mt-slider'][data-slider-wrapper='horizontal'] [data-slider] {
    white-space: nowrap !important;
}

[id^='mt-slider'][data-slider-wrapper='horizontal'] [data-slide] {
    display: inline-block !important;
    white-space: normal;
    vertical-align: top;
}

[id^='mt-slider'][data-slider-wrapper='vertical'] [data-slide] {
    display: block !important;
}

[id^='mt-slider'][data-slider-wrapper='fade'] [data-slide] {
    position: absolute;
    top: 0;
    opacity: 0;
}

[id^='mt-slider'][data-slider-wrapper='fade'] [data-slide][data-slide-active] {
    position: relative;
    z-index: 1;
    opacity: 1;
}

[id^='mt-slider'] [data-pagination-slide],
[id^='mt-slider'] [data-prev-button],
[id^='mt-slider'] [data-next-button] {
    cursor: pointer;
    z-index: 1;
}

[id^='mt-slider'][data-slider-wrapper='vertical'] [data-slider-mask] {
    touch-action: pan-x;
}
[id^='mt-slider'][data-slider-wrapper='horizontal'] [data-slider-mask],
[id^='mt-slider'][data-slider-wrapper='fade'] [data-slider-mask] {
    touch-action: pan-y;
}

[id^='mt-slider'] [data-pagination-slide] * {
    pointer-events: none;
}

/** ----END DEFAULT STYLES---- */

stylesheets/widgets/monetate.less – our override of any styling to the defaults:

@import 'monetate-default.less';

/**
 * CUSTOM SLIDER STYLING
 */
[id^='mt-slider'] {
    margin-top: 3rem;
    text-align: center;

    &[data-slider-wrapper='vertical'] {
        [data-prev-button] {
            left: 50%;
            top: 0;
            transform: translateX(-50%);
        }

        [data-next-button] {
            bottom: 0;
            left: 50%;
            right: auto;
            top: auto;
            transform: translateX(-50%);
        }

        [data-slider-mask] {
            height: 400px !important;
        }
    }

    & > div:first-child {
        border-bottom: 1px solid #000;
        font-size: 2rem;
    }

    [data-pagination-slide] {
        background: #000;
        border-radius: 6px;
        display: inline-block;
        height: 12px;
        margin: 4px;
        opacity: 0.5;
        width: 12px;

        &[data-slide-active] {
            opacity: 1;
        }
    }

    [data-pagination='thumbnails'] [data-pagination-slide] {
        border-radius: 0;
        background: none;
        width: 40px;
        height: auto;
    }

    [disabled] {
        opacity: 0.5;
    }


    [data-slide] {
        > div {
            height: 100%;
            padding: 10px;
        }

        img {
            max-height: 100%;
            max-width: 100%;
        }

        a {
            display: flex;
            flex-flow: column;
        }
    }

    [data-prev-button],
    [data-next-button] {
        background: none;
        color: #000;
        font-size: 25px;
        padding: 0;
        position: absolute;
        top: 45%;
        user-select: none;
    }

    [data-prev-button] {
        left: 10px;
    }

    [data-next-button] {
        right: 10px;
    }

    [data-slider-mask] {
        display: inline-block;
        margin-top: 1rem !important;
        width: calc(100% - 106px);
    }

    [data-pagination*='dots'] [data-pagination-slide] {
        /* remove below for pagination numbers */
        text-indent: -9999px;
    }

}

templates/page.hypr – the core theme file for all pages:

{% comment %}
we’ll modify the <head> tag to allow for one of the optional outputs of the monetate script
{% endcomment %}
<head>
	{% include "modules/monetate/script" %}
	…the rest of the <head> content
</head>

templates/modules/trailing-scripts.hypr – a core file representing the final output of the generated JavaScript files:

{% comment %}
And this handles the other use case if we want monetate loaded synchronously, inserted at the top of the file
{% endcomment %}
{% with themeSettings.useDebugScripts|default:pageContext.isDebugMode as debugMode %}
	{% if themeSettings.loadMonetateScript && !themeSettings.loadMonetateAsynchronous %}
		{% require_script "widgets/monetate/monetate-observation" %}
	{% endif %}
{%endwith%}

Finally:

That wraps up the output for Monetate!

You won’t see anything from the front-end perspective, as this will all be code output, and this showcases the automatically generated Monetate content (with the image URL modified):

kibo ecommerce themes

Wrapping Up Widgets

Above, we have two examples: one an extremely simple showcase to introduce the idea of building a widget and how one can think about building the panel and handling the logic of showing/hiding the output in your template. The second is a closer “real-world” example of how you can use the widget to allow developers to set up the heavy lifting, while still enabling the folks on the business end of your operation to handle configuration.

By no means do you have to expose every configuration option to a business user—you know them better than anyone, after all! But you can give them the ability to modify options and attributes your code consumes to allow them the ability to fine-tune it on their own (and you can place appropriate checks in your code to prevent future errors).


This post was written by Keith Baker, Sr. Enterprise Solution Developer, Kibo