Testimonials
Testimonials carousel. Use left and right arrow keys to change slides.
` would end this element.
//
// WHITESPACE CONTROL: the first Liquid tag below must open WITHOUT a left
// dash and the last must close WITHOUT a right dash, or Liquid eats the
// newline after this comment and the one before `const PAGE_TEMPLATE`,
// gluing that declaration onto the comment line. Shipped that way once:
// PAGE_TEMPLATE was never declared, fetchAssignments threw, and every page
// on every install fell back to control with nothing logged. (The tag
// delimiters are not written out here because Liquid would parse them.)
const PAGE_TEMPLATE = "";
const PRODUCT_TAGS = null;
const urlParams = new URLSearchParams(location.search);
let preview = urlParams.get('splt_preview') || getCookie(COOKIE.PREVIEW) || null;
if (urlParams.get('clear-preview') === 'true') {
clearCookie(COOKIE.PREVIEW);
clearCookie(COOKIE.PREVIEW_HIDDEN);
preview = null;
}
// PREVIEW_TTL, not ASSIGNMENTS_TTL. The cookie must never outlive the token
// it holds. Preview tokens are signed for one hour (TOKEN_EXPIRATION_MS in
// app/lib/preview-token.ts) but this cookie was kept for seven days, so once
// the token lapsed every page load replayed a dead token, the proxy answered
// 401, and the embed fell back to control. The merchant saw "preview is
// broken" and, worse, normal storefront browsing stayed broken too, because a
// preview cookie sets isPreview and bypasses the no-active-tests gate.
else if (urlParams.get('splt_preview')) {
setCookie(COOKIE.PREVIEW, preview, PREVIEW_TTL);
// Arriving with a token in the URL is a deliberate click from the admin,
// so it un-hides the banner. Without this a merchant who hid it once would
// never see it again and would have no way to get it back short of
// clearing cookies.
clearCookie(COOKIE.PREVIEW_HIDDEN);
}
// `let`, not const: fetchAssignments clears both this and the cookie when the
// proxy rejects the token, so the rest of the page behaves as a normal
// visitor instead of staying stuck in a broken preview.
let isPreview = !!preview;
splt.isPreview = isPreview;
// Bail before any proxy call if the shop has no active tests and the
// visitor isn't previewing — saves a request per page view.
if (!hasActiveSplitTestsFromMetafield && !isPreview) {
log('No active tests and not previewing, skipping proxy call');
publishAssignments([]);
// Collapse to control, exactly as every other bail-out does. This one
// used to return without it, on the reasoning that no active tests means
// nothing to swap. That is only true if the theme markup went away at
// the same moment the test stopped, and it does not: the merchant pauses
// or completes their last test, the markup stays in the theme, this
// branch is taken on every page load, and the shopper sees EVERY variant
// at once, permanently, with nothing logged.
//
// Deferred because this branch runs while the head is still parsing, so
// the body does not exist yet and an immediate call would query an empty
// document and silently do nothing. Deferring showPage costs nothing
// here: the hide style is only emitted when the metafield says a test is
// running, so on this path the page was never hidden.
runReveal(() => { applyControlFallback(); showPage(); });
return;
}
function showPage() {
document.getElementById('splt-embed-hide-page')?.remove();
document.getElementById('splt-embed-script')?.remove();
}
function selectVariant(assignments, splitId) {
// Merchants type either the raw test UUID OR the merchant-defined
// handle into `data-split-id`. The proxy returns both fields on
// each assignment so theme markup can use whichever is more
// readable (handle in practice — UUIDs in theme code are unusable).
return assignments.find((a) => a.splitTestId === splitId || a.splitTestHandle === splitId)?.variant ?? null;
}
function applyAssignments(assignments) {
// Notify any custom code listening for assignment data.
document.dispatchEvent(new CustomEvent('apply_assignments', { detail: { assignments } }));
const elements = document.querySelectorAll('[data-split-id][data-split-variant]');
let applied = 0;
// Tests that are on the page but absent from the response. Collapsing them
// to control is NOT optional, and "leave the element alone" (what this used
// to do) is the bug: the app's own primary documented pattern tells
// merchants to DUPLICATE the markup, one element per variant, so leaving
// them alone renders every variant at once. The shopper sees both
// headlines.
//
// A 200 response routinely omits a test: the visitor was excluded by an
// audience rule, the shop hit its plan cap (which is meant to stop
// assigning, not to break pages), an assignment insert failed inside the
// allSettled, or the test is a draft. Every one of those wants the default
// experience, which is exactly control-only.
//
// Same first-wins-per-id convention as applyControlFallback, and document
// order is the authoring order, so the first is the control.
const unassignedSeen = new Set();
elements.forEach((el) => {
const { splitId, splitVariant, splitStyles, splitAddClasses, splitRemoveClasses } = el.dataset;
const variant = selectVariant(assignments, splitId);
if (!variant) {
if (unassignedSeen.has(splitId)) {
el.remove();
} else {
unassignedSeen.add(splitId);
}
return;
}
const isThisVariant = variant.handle === splitVariant || variant.id === splitVariant;
if (isThisVariant) {
if (el.tagName === 'TEMPLATE') {
el.parentNode.insertBefore(el.content.cloneNode(true), el);
el.remove();
}
if (splitStyles) applySanitizedStyles(el, splitStyles);
if (splitAddClasses) el.classList.add(...splitAddClasses.split(' '));
if (splitRemoveClasses) el.classList.remove(...splitRemoveClasses.split(' '));
// Per-attribute swaps via data-split-set-attr-
Testimonials carousel. Use left and right arrow keys to change slides.
` AND
// `
` rendered side by side. Convention:
// the FIRST `[data-split-variant]` per `data-split-id` is the control;
// remove all the others. Templates collapse harmlessly (their content
// never inserted), styles/classes stay as-authored.
function applyControlFallback() {
const seen = new Set();
const elements = document.querySelectorAll('[data-split-id][data-split-variant]');
elements.forEach((el) => {
const id = el.dataset.splitId;
if (!id) return;
if (seen.has(id)) {
el.remove();
} else {
seen.add(id);
}
});
}
function updateCart(userId) {
// Stamping the cart with the user ID lets order webhooks tie an order
// back to the visitor's assignment without needing the email match.
//
// Prefer sendBeacon over fetch — beacon survives the page unload
// event so a shopper who clicks "Checkout" immediately after the
// first page paint doesn't lose the attribute mid-flight. Falls
// back to fetch (no await) when beacon isn't available.
if (!userId || existingCartUserId === userId) return;
const body = JSON.stringify({ attributes: { splt_user_id: userId } });
try {
if (navigator.sendBeacon) {
const blob = new Blob([body], { type: 'application/json' });
navigator.sendBeacon('/cart/update.js', blob);
return;
}
} catch (err) { log('beacon failed', err); }
try {
fetch('/cart/update.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
}).catch((err) => log('cart update failed', err));
} catch (err) { log('cart update failed', err); }
}
// Preview URL for one variant: same page, same query string, different
// token. Staying on the current path matters — a merchant previewing a
// product page wants the other variant of THAT page, not the home page.
function previewUrlFor(token) {
const url = new URL(location.href);
url.searchParams.set('splt_preview', token);
// Arriving with ?clear-preview=true would immediately end the session we
// are trying to switch within.
url.searchParams.delete('clear-preview');
return url.toString();
}
// The variant name as a dropdown: click it, pick another variant of the
// same test, land on the same page rendering that variant.
function buildVariantMenu(currentName, previewInfo, banner) {
const wrap = document.createElement('div');
wrap.style.cssText = 'flex:1;position:relative';
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('aria-haspopup', 'listbox');
button.setAttribute('aria-expanded', 'false');
button.style.cssText =
'display:inline-flex;align-items:center;gap:6px;width:100%;border:0;background:transparent;' +
'padding:4px 6px;margin:-4px -6px;border-radius:6px;cursor:pointer;font:inherit;' +
'font-weight:600;color:#202223;text-align:left;transition:background-color .12s';
const buttonText = document.createElement('span');
buttonText.textContent = currentName || 'Preview Mode';
button.appendChild(buttonText);
const caret = document.createElement('span');
caret.setAttribute('aria-hidden', 'true');
caret.style.cssText = 'display:inline-flex;color:#5C5F62;transition:transform .12s';
caret.innerHTML =
'';
button.appendChild(caret);
button.addEventListener('mouseenter', function () { button.style.backgroundColor = '#F1F2F3'; });
button.addEventListener('mouseleave', function () { button.style.backgroundColor = 'transparent'; });
button.addEventListener('focus', function () {
button.style.boxShadow = '0 0 0 2px #005BD3';
button.style.outline = 'none';
});
button.addEventListener('blur', function () { button.style.boxShadow = 'none'; });
const menu = document.createElement('div');
menu.setAttribute('role', 'listbox');
menu.style.cssText =
'display:none;position:absolute;top:calc(100% + 6px);left:-6px;min-width:180px;' +
'background:#fff;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.18);padding:4px;' +
'z-index:1;max-height:260px;overflow-y:auto';
// Names the test the variants belong to. A merchant running several tests
// otherwise has no way to tell which one this banner is for.
if (previewInfo.testName) {
const heading = document.createElement('div');
heading.textContent = previewInfo.testName;
heading.style.cssText =
'padding:6px 10px 4px;font-size:11px;font-weight:700;color:#6D7175;' +
'text-transform:uppercase;letter-spacing:.4px';
menu.appendChild(heading);
}
previewInfo.variants.forEach(function (v) {
if (!v.token) return;
const isCurrent = v.handle === previewInfo.current;
const item = document.createElement('a');
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', isCurrent ? 'true' : 'false');
// A real link, so the merchant can middle-click to compare two variants
// side by side in separate tabs.
item.href = previewUrlFor(v.token);
item.style.cssText =
'display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:6px;' +
'font-size:13px;font-weight:' + (isCurrent ? '600' : '500') + ';color:#202223;' +
'text-decoration:none;transition:background-color .12s';
item.addEventListener('mouseenter', function () { item.style.backgroundColor = '#F1F2F3'; });
item.addEventListener('mouseleave', function () { item.style.backgroundColor = 'transparent'; });
const check = document.createElement('span');
check.setAttribute('aria-hidden', 'true');
check.style.cssText = 'width:14px;display:inline-flex;color:#005BD3;flex-shrink:0';
if (isCurrent) {
check.innerHTML =
'';
}
item.appendChild(check);
// textContent, not innerHTML — variant names are merchant-authored and
// this banner renders on the storefront.
const text = document.createElement('span');
text.textContent = v.name || v.handle;
item.appendChild(text);
menu.appendChild(item);
});
let open = false;
function setOpen(next) {
open = next;
menu.style.display = next ? 'block' : 'none';
button.setAttribute('aria-expanded', next ? 'true' : 'false');
caret.style.transform = next ? 'rotate(180deg)' : 'none';
}
button.addEventListener('click', function (e) {
e.stopPropagation();
setOpen(!open);
});
// Clicking anywhere else, or Escape, closes it. Scoped to document
// because the banner floats over a page we do not control.
document.addEventListener('click', function (e) {
if (open && !banner.contains(e.target)) setOpen(false);
});
document.addEventListener('keydown', function (e) {
if (open && e.key === 'Escape') {
setOpen(false);
button.focus();
}
});
wrap.appendChild(button);
wrap.appendChild(menu);
return wrap;
}
function showPreviewBanner(name, previewInfo) {
// DOM-build the banner instead of innerHTML — variant names are
// merchant-controlled but we don't want a stored-XSS surface here.
if (document.getElementById('splt-preview-banner')) return;
// Dismissed for this preview session. Reset whenever the merchant arrives
// with a fresh token in the URL, i.e. clicks Preview again from the admin.
if (getCookie(COOKIE.PREVIEW_HIDDEN)) return;
const banner = document.createElement('div');
banner.id = 'splt-preview-banner';
banner.style.cssText =
'position:fixed;top:20px;right:20px;background:#fff;color:#202223;padding:12px 16px;' +
'border-radius:8px;z-index:9999999;font-family:-apple-system,sans-serif;font-size:14px;' +
'box-shadow:0 4px 16px rgba(0,0,0,.15);min-width:280px;max-width:400px;';
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:10px';
const tag = document.createElement('div');
tag.textContent = 'Preview';
tag.style.cssText =
'background:#E8F5E9;border-radius:6px;padding:5px 10px;font-size:11px;font-weight:700;' +
'color:#1B5E20;text-transform:uppercase;letter-spacing:.5px';
// The variant name is a menu when there is more than one variant to
// switch to, and plain text otherwise. Previously it was always plain
// text, so seeing the other side of a test meant going back to the admin
// and clicking a different preview link.
const others = (previewInfo?.variants || []).filter(function (v) { return v.token; });
const label = others.length > 1
? buildVariantMenu(name, previewInfo, banner)
: (function () {
const span = document.createElement('span');
span.style.cssText = 'flex:1;font-weight:600';
span.textContent = name || 'Preview Mode';
return span;
})();
const exit = document.createElement('a');
exit.href = '?clear-preview=true';
exit.textContent = 'Exit';
exit.style.cssText =
'color:#fff;background:#1A1A1A;padding:7px 14px;border-radius:7px;font-size:13px;' +
'font-weight:600;text-decoration:none';
// Hide, distinct from Exit.
//
// Exit ends the preview session entirely (clears the cookie, back to a
// normal visitor). Hide just gets the banner out of the way so the
// merchant can look at the page it is covering, while STAYING in preview.
// Those are different intentions and the banner previously only offered
// the destructive one.
//
// Dismissal is remembered per preview session and reset by a fresh click
// from the admin, which is exactly what arriving with ?splt_preview= in
// the URL means (see the clearCookie call at the preview-detection block).
// So hiding sticks while browsing the storefront, and pressing Preview
// again brings it back without the merchant having to know about cookies.
const hide = document.createElement('button');
hide.type = 'button';
hide.title = 'Hide';
hide.setAttribute('aria-label', 'Hide the preview banner');
hide.style.cssText =
'display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;' +
'border:0;border-radius:6px;background:transparent;cursor:pointer;padding:0;color:#5C5F62;' +
'transition:background-color .12s,color .12s';
// An icon-only button must not look identical on hover, or it reads as
// decoration rather than a control.
hide.addEventListener('mouseenter', function () {
hide.style.backgroundColor = '#F1F2F3';
hide.style.color = '#202223';
});
hide.addEventListener('mouseleave', function () {
hide.style.backgroundColor = 'transparent';
hide.style.color = '#5C5F62';
});
hide.addEventListener('focus', function () {
hide.style.boxShadow = '0 0 0 2px #005BD3';
hide.style.outline = 'none';
});
hide.addEventListener('blur', function () {
hide.style.boxShadow = 'none';
});
// Eye-off, drawn inline so the banner stays dependency-free.
hide.innerHTML =
'';
hide.addEventListener('click', function () {
setCookie(COOKIE.PREVIEW_HIDDEN, '1', PREVIEW_TTL);
banner.remove();
});
row.appendChild(tag);
row.appendChild(label);
row.appendChild(hide);
row.appendChild(exit);
banner.appendChild(row);
(document.body || document.documentElement).prepend(banner);
}
async function fetchAssignments(signal) {
// The URL the proxy evaluates urlIncludes/urlExcludes against, and so
// the URL a URL-scoped response is only valid for. Built once here so
// the cache check below and the request body compare the same string.
const pageUrl = location.pathname + location.search;
// Version-stamped cookie cache. Cookie payload shape:
// { v:
Testimonials
"The pattern is the detail I keep coming back to. Subtle up close, but it gives the whole watch a quiet sense of intent. Thank you for getting it right."