Created
April 17, 2026 20:39
-
-
Save ceaksan/65245a3b07a0a7707891867f578540d9 to your computer and use it in GitHub Desktop.
Cart Abandonment State Machine - funnel-aware client-side abandonment detector with grace period, BFCache support and cross-tab sync.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Cart Abandonment State Machine | |
| * | |
| * Funnel-aware client-side cart abandonment detector. Distinguishes | |
| * real abandonment (cart -> homepage, cart -> blog, cart -> exit) | |
| * from normal in-funnel behavior (cart -> product, cart -> checkout). | |
| * | |
| * Features: | |
| * - Regex-based strict path matching per URL type | |
| * - Grace period to swallow fast re-entries (e.g. cart -> product -> cart) | |
| * - Whitelist of checkout steps (login, shipping, payment) as non-abandon | |
| * - BFCache re-run via pageshow event | |
| * - Cross-tab sync: purchase in tab B cancels pending abandonment in tab A | |
| * | |
| * Configure CONFIG.paths regex groups for your platform (Ticimax, IdeaSoft, | |
| * T-Soft, Shopify, custom). | |
| * | |
| * Install via: GTM Custom HTML tag, trigger = All Pages / DOM Ready. | |
| * On abandonment the code pushes { event: 'cart_abandonment', ... } to | |
| * dataLayer; wire a GTM Custom Event trigger to your remarketing tags. | |
| * | |
| * @see https://ceaksan.com/tr/sepet-terk-takibi-javascript | |
| * @license MIT | |
| */ | |
| (function () { | |
| "use strict"; | |
| // Platform configuration: adjust regexes to match your store URLs | |
| var CONFIG = { | |
| paths: { | |
| cart: /^\/(sepet|cart|basket)(\/|$)/i, | |
| checkout: [ | |
| /^\/(odeme|checkout|payment)(\/|$)/i, | |
| /^\/(teslimat|shipping|adres)(\/|$)/i, | |
| /^\/(uyelik|login|uye-giris)(\/|$)/i, | |
| ], | |
| orderSuccess: | |
| /^\/(siparis-sonuc|siparis-onay|siparis-basarili|siparistamamlandi|thank[-_]?you)(\/|$)/i, | |
| product: /^\/(urun|product|p)\//i, | |
| }, | |
| storageKey: "cart_journey", | |
| crossTabKey: "cart_sync", | |
| graceDelay: 5000, | |
| maxSessionAge: 30 * 60 * 1000, | |
| }; | |
| var STATES = { | |
| IN_CART: "in_cart", | |
| BROWSING: "browsing_from_cart", | |
| CHECKOUT: "checkout", | |
| PENDING: "pending_abandonment", | |
| }; | |
| function getState() { | |
| try { | |
| var raw = sessionStorage.getItem(CONFIG.storageKey); | |
| if (!raw) return null; | |
| var parsed = JSON.parse(raw); | |
| if (Date.now() - parsed.lastUpdated > CONFIG.maxSessionAge) { | |
| sessionStorage.removeItem(CONFIG.storageKey); | |
| return null; | |
| } | |
| return parsed; | |
| } catch (e) { | |
| return null; | |
| } | |
| } | |
| function setState(data) { | |
| data.lastUpdated = Date.now(); | |
| sessionStorage.setItem(CONFIG.storageKey, JSON.stringify(data)); | |
| } | |
| function clearState() { | |
| sessionStorage.removeItem(CONFIG.storageKey); | |
| // Notify other tabs so they cancel any pending abandonment | |
| try { | |
| localStorage.setItem( | |
| CONFIG.crossTabKey, | |
| JSON.stringify({ action: "purchase_complete", t: Date.now() }), | |
| ); | |
| } catch (e) {} | |
| } | |
| function getPageType(path) { | |
| if (CONFIG.paths.orderSuccess.test(path)) return "order"; | |
| if (CONFIG.paths.cart.test(path)) return "cart"; | |
| if ( | |
| CONFIG.paths.checkout.some(function (p) { | |
| return p.test(path); | |
| }) | |
| ) | |
| return "checkout"; | |
| if (CONFIG.paths.product.test(path)) return "product"; | |
| return "other"; | |
| } | |
| function fireEvent(state) { | |
| // Re-check: another tab may have cleared the state | |
| var current = getState(); | |
| if (!current || current.state !== STATES.PENDING) return; | |
| var timeInCart = Date.now() - (state.entryTime || Date.now()); | |
| function push() { | |
| window.dataLayer = window.dataLayer || []; | |
| window.dataLayer.push({ | |
| event: "cart_abandonment", | |
| abandonmentContext: { | |
| timeInCartMs: timeInCart, | |
| fromCheckout: current.fromCheckout || false, | |
| exitPage: window.location.pathname, | |
| }, | |
| }); | |
| } | |
| // Defer if GTM hasn't loaded yet | |
| if (window.google_tag_manager) { | |
| push(); | |
| } else { | |
| setTimeout(push, 1000); | |
| } | |
| sessionStorage.removeItem(CONFIG.storageKey); | |
| } | |
| function handleTransition() { | |
| var path = window.location.pathname; | |
| var pageType = getPageType(path); | |
| var state = getState(); | |
| var now = Date.now(); | |
| // Order success: clear everything | |
| if (pageType === "order") { | |
| clearState(); | |
| return; | |
| } | |
| // Cart page: start or refresh state | |
| if (pageType === "cart") { | |
| setState({ | |
| state: STATES.IN_CART, | |
| entryTime: state ? state.entryTime : now, | |
| lastCartTime: now, | |
| }); | |
| return; | |
| } | |
| // Checkout step: in funnel, not abandonment | |
| if (pageType === "checkout") { | |
| if ( | |
| state && | |
| (state.state === STATES.IN_CART || state.state === STATES.BROWSING) | |
| ) { | |
| setState({ | |
| state: STATES.CHECKOUT, | |
| entryTime: state.entryTime, | |
| checkoutEntryTime: now, | |
| }); | |
| } | |
| return; | |
| } | |
| // Outside cart/checkout | |
| if (!state) return; | |
| // Cart -> product: start grace period | |
| if (state.state === STATES.IN_CART && pageType === "product") { | |
| setState({ | |
| state: STATES.BROWSING, | |
| entryTime: state.entryTime, | |
| lastCartTime: state.lastCartTime, | |
| browseStartTime: now, | |
| }); | |
| return; | |
| } | |
| // Product -> product: still browsing | |
| if (state.state === STATES.BROWSING && pageType === "product") { | |
| setState(Object.assign({}, state, { lastUpdated: now })); | |
| return; | |
| } | |
| // Real abandonment scenarios: | |
| // 1. cart -> other (non-product) | |
| // 2. browsing -> other (didn't return to cart) | |
| // 3. checkout -> other (broke funnel) | |
| var isAbandonment = | |
| (state.state === STATES.IN_CART && pageType === "other") || | |
| (state.state === STATES.BROWSING && pageType === "other") || | |
| state.state === STATES.CHECKOUT; | |
| if (isAbandonment) { | |
| setState({ | |
| state: STATES.PENDING, | |
| entryTime: state.entryTime, | |
| fromCheckout: state.state === STATES.CHECKOUT, | |
| }); | |
| // Grace period: swallows fast back-navigation | |
| setTimeout(function () { | |
| fireEvent(state); | |
| }, CONFIG.graceDelay); | |
| } | |
| } | |
| // Cross-tab sync: purchase in another tab cancels pending abandonment | |
| window.addEventListener("storage", function (e) { | |
| if (e.key === CONFIG.crossTabKey && e.newValue) { | |
| try { | |
| var data = JSON.parse(e.newValue); | |
| if (data.action === "purchase_complete") { | |
| sessionStorage.removeItem(CONFIG.storageKey); | |
| } | |
| } catch (err) {} | |
| } | |
| }); | |
| // BFCache: re-run when restored from back/forward cache | |
| window.addEventListener("pageshow", function (e) { | |
| if (e.persisted) { | |
| setTimeout(handleTransition, 0); | |
| } | |
| }); | |
| // Kickoff | |
| if (document.readyState === "loading") { | |
| document.addEventListener("DOMContentLoaded", handleTransition); | |
| } else { | |
| handleTransition(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment