Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,4 @@ jobs:
node-version: "22"
- run: npm install
- run: npm run build
- run: npm test
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,24 @@ $this->app->bind(CheckoutTheme::class, fn () =>
Per-channel theming: bind a closure that resolves the theme from the current
channel. Same seam.

### Head hook and analytics

The checkout is its own document, so anything the store needs in its `<head>`
(a tag manager, a consent script, verification meta tags) goes in
`resources/views/vendor/lunar-checkout/partials/head.blade.php`. It renders
last in the head and is empty by default.

The checkout pushes three GA4 events to `window.dataLayer` when one exists:

- `begin_checkout`, once per checkout session, when it first loads (so the
store doesn't send its own from the button that leads here);
- `add_shipping_info` with `shipping_tier`, when a delivery method or
collection is chosen (or at pay, if the server chose it);
- `add_payment_info` with `payment_type` (the payment method handle, e.g. `card` or `on-account`), when the customer pays.

Both carry `currency`, `value` and `items`. Nothing is sent without a data
layer, so create it in the head hook only once the shopper has consented.

---

## 4. Tier 2 — Add your own element or gateway (no publish, no rebuild)
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"description": "Build toolchain for the self-contained Lunar checkout Inertia app (spec 0008).",
"scripts": {
"dev": "vite",
"build": "vite build"
"build": "vite build",
"test": "node --test tests/js/*.test.js"
},
"dependencies": {
"@inertiajs/vue3": "^3.0",
Expand Down
92 changes: 0 additions & 92 deletions resources/dist/build/assets/StripeExpress-B8vLxJvM.js

This file was deleted.

92 changes: 92 additions & 0 deletions resources/dist/build/assets/StripeExpress-D2omc5gD.js

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions resources/dist/build/manifest.json
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
{
"_StripeExpress-B8vLxJvM.js": {
"file": "assets/StripeExpress-B8vLxJvM.js",
"_StripeExpress-D2omc5gD.js": {
"file": "assets/StripeExpress-D2omc5gD.js",
"name": "StripeExpress"
},
"resources/js/app.js": {
"file": "assets/app-BTCEeZdH.js",
"file": "assets/app-BKHspGPZ.js",
"name": "app",
"src": "resources/js/app.js",
"isEntry": true,
"imports": [
"_StripeExpress-B8vLxJvM.js"
"_StripeExpress-D2omc5gD.js"
],
"css": [
"assets/app-CpM2wqoC.css"
]
},
"resources/js/express.js": {
"file": "assets/express-BuS9V1WX.js",
"file": "assets/express-3pZq12Nj.js",
"name": "express",
"src": "resources/js/express.js",
"isEntry": true,
"imports": [
"_StripeExpress-B8vLxJvM.js"
"_StripeExpress-D2omc5gD.js"
]
}
}
40 changes: 40 additions & 0 deletions resources/js/composables/useCheckout.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { computed, inject, provide, reactive, watch } from 'vue'
import { router } from '@inertiajs/vue3'
import { money } from '../utils/money.js'
import { trackCheckoutEvent, trackCheckoutStart } from '../utils/analytics.js'

export const CHECKOUT_KEY = Symbol('lunar-checkout')

Expand Down Expand Up @@ -136,6 +137,9 @@ export function createCheckout(data) {

state.method = state.paymentMethods[0]?.handle ?? null

// GA4 begin_checkout for a host running Tag Manager, once per session.
trackCheckoutStart(data.uuid, { items: state.items, currency: state.currency })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

begin_checkout now also fired here; may duplicate storefront's event
Medium · Quick check

The PR description and KJS-209 say begin_checkout is already sent by the storefront (KJS-97, basket and Buy Now). This PR adds trackCheckoutStart, which fires begin_checkout again on checkout load. The README even says the store should not send its own. If the storefront still sends it, GA4 will count two begin_checkout events per checkout and distort funnel numbers. The change is also missing from the PR description's change list.

Suggested fix: either remove it from the package, or confirm that KJS-97's event is removed in KJP and document this in the PR. The sessionStorage dedupe only protects against reloads within this app. It doesn't protect against the storefront's event.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No double count: KJP already stopped sending its own begin_checkout in neondigital/kjpspares.co.uk@c44afc6d (useAddToCart.test.ts checks it stays out). The checkout owns the event now. I've added it to the PR description's change list.


// Bounced back from the processing page: the gateway reported the charge
// failed and the session was reopened for another attempt.
if (new URLSearchParams(window.location.search).get('payment') === 'failed') {
Expand Down Expand Up @@ -360,6 +364,30 @@ export function createCheckout(data) {
},
)

// GA4 step events for a host running Tag Manager (utils/analytics.js).
// Shipping is sent once per tier chosen, payment once per method paid with,
// so a retry or a reselection is not counted twice.
const trackedShipping = new Set()
const trackedPayment = new Set()

function trackShipping(tier) {
if (!tier || trackedShipping.has(tier)) return

if (trackCheckoutEvent('add_shipping_info', { items: state.items, currency: state.currency, shipping_tier: tier })) {
trackedShipping.add(tier)
}
}

function trackPayment(method) {
const type = method?.handle

if (!type || trackedPayment.has(type)) return

if (trackCheckoutEvent('add_payment_info', { items: state.items, currency: state.currency, payment_type: type })) {
trackedPayment.add(type)
}
}

// Select a shipping option. Optimistic highlight, server-confirmed — the
// reload brings back the cart's stored selection and recalculated totals.
function selectShipping(id) {
Expand All @@ -373,6 +401,9 @@ export function createCheckout(data) {
preserveScroll: true,
preserveState: true,
only: ['checkout'],
onSuccess: () => {
trackShipping(state.shippingMethods.find((m) => m.id === id)?.name)
},
onError: () => {
state.shippingId = previous
},
Expand All @@ -395,6 +426,9 @@ export function createCheckout(data) {
preserveScroll: true,
preserveState: true,
only: ['checkout'],
onSuccess: () => {
if (mode === 'collect') trackShipping(collectOption.value?.name ?? 'Collect')
},
onError: () => {
state.fulfilment = previous
},
Expand Down Expand Up @@ -527,6 +561,12 @@ export function createCheckout(data) {
return
}

// The step before payment may never have been a click: a single
// shipping option the server chose, say. Send it now so the funnel
// has no gap, then the payment step.
trackShipping(state.fulfilment === 'collect' ? (collectOption.value?.name ?? 'Collect') : shippingMethod.value?.name)
trackPayment(activePaymentMethod.value)

state.processing = true
state.payError = ''

Expand Down
92 changes: 92 additions & 0 deletions resources/js/utils/analytics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// GA4 checkout events (begin_checkout, add_shipping_info, add_payment_info)
// for a store running Google Tag Manager.
//
// The checkout never loads a tag itself. A host that wants measurement puts
// its container (and its consent handling) in the head hook,
// resources/views/vendor/lunar-checkout/partials/head.blade.php, and creates
// `window.dataLayer` only when the shopper has consented. No data layer, no
// events: a store without Tag Manager, or a shopper who said no, sends nothing.

/** Decimal places of a currency's minor unit: 2 for GBP, 0 for JPY. */
function currencyDecimals(currency) {
try {
return new Intl.NumberFormat('en', { style: 'currency', currency }).resolvedOptions().maximumFractionDigits ?? 2
} catch {
return 2
}
}

/** Minor units (1299) to GA4's major units (12.99). */
export function fromMinor(minor, currency) {
return Math.round(minor || 0) / 10 ** currencyDecimals(currency)
}

/** Checkout lines as GA4 items, priced per unit. */
export function analyticsItems(items, currency) {
return items.map((item) => ({
item_id: item.sku ?? String(item.id),
item_name: item.title ?? '',
price: fromMinor(item.price, currency),
quantity: item.qty,
}))
}

function itemsValue(items) {
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0)

// Floating point noise only (0.1 * 3), not real precision.
return Math.round(total * 1e6) / 1e6
}

/**
* Push a GA4 e-commerce event, if the host has a data layer. Returns whether
* it was sent.
*/
export function trackCheckoutEvent(event, { items, currency, ...extra }) {
if (typeof window === 'undefined' || !Array.isArray(window.dataLayer)) return false

const lines = analyticsItems(items, currency)

// Clear the previous event's ecommerce object, as Google recommends, so
// fields don't leak from one event into the next.
window.dataLayer.push({ ecommerce: null })
window.dataLayer.push({
event,
ecommerce: { currency, value: itemsValue(lines), ...extra, items: lines },
})

return true
}

/** Checkout sessions already announced in this tab, so a reload isn't a second start. */
const BEGUN_KEY = 'lunar_checkout_begun'

/**
* begin_checkout, once per checkout session. Sent from the checkout itself
* rather than the store's "Checkout" button: the button's request ends in a
* full page visit, so the store can't tell it succeeded, and every way in
* (basket, Buy Now, a resumed link) lands here.
*/
export function trackCheckoutStart(uuid, payload) {
if (!uuid) return false

let begun = []

try {
begun = JSON.parse(window.sessionStorage.getItem(BEGUN_KEY) ?? '[]')
} catch {
// Storage blocked or corrupt: send it rather than lose the step.
}

if (begun.includes(uuid)) return false

if (!trackCheckoutEvent('begin_checkout', payload)) return false

try {
window.sessionStorage.setItem(BEGUN_KEY, JSON.stringify([...begun, uuid].slice(-20)))
} catch {
// As above.
}

return true
}
5 changes: 5 additions & 0 deletions resources/views/app.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@
@if (! empty($page['props']['stylesheet']))
<link rel="stylesheet" href="{{ $page['props']['stylesheet'] }}">
@endif

{{-- The host's head hook (tag manager, consent, verification tags). Empty
unless the store publishes resources/views/vendor/lunar-checkout/
partials/head.blade.php. --}}
@include('lunar-checkout::partials.head')
</head>
<body>
@inertia
Expand Down
11 changes: 11 additions & 0 deletions resources/views/partials/head.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{{--
The host's slot in the checkout's <head>, rendered last. Empty here; a
store fills it by publishing its own copy to
resources/views/vendor/lunar-checkout/partials/head.blade.php, for a tag
manager, a consent script or verification meta tags. $page (the Inertia
page) is in scope.

The checkout pushes GA4 step events (add_shipping_info, add_payment_info)
to window.dataLayer when one exists, so a store that wants them creates
it here, and only once the shopper has consented.
--}}
30 changes: 30 additions & 0 deletions tests/Feature/CheckoutThemeBrandingTest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\View;
use Lunar\Checkout\DataObjects\CheckoutTheme;
use Lunar\Tests\Checkout\TestCase;
use Lunar\Tests\Checkout\Utils\CheckoutCart;
Expand Down Expand Up @@ -53,3 +54,32 @@
'--accent-ring' => 'rgba(45, 142, 66, 0.16)',
]);
});

/**
* The host's head hook: a store overriding lunar-checkout::partials.head
* (a tag manager, a consent script) gets it into the checkout document.
*/
it('renders the host head partial when the store overrides it', function () {
$views = sys_get_temp_dir().'/lunar-checkout-head-'.uniqid();
mkdir($views.'/partials', recursive: true);
file_put_contents($views.'/partials/head.blade.php', '<script data-test="host-head">window.hostHead = true</script>');

View::prependNamespace('lunar-checkout', $views);

$cart = CheckoutCart::orderable();
$session = CheckoutCart::session($cart);

$this->withoutVite()->get(route('lunar.checkout.show', $session->uuid))
->assertOk()
->assertSee('<script data-test="host-head">window.hostHead = true</script>', false);
});

it('adds nothing to the head when the store leaves the hook alone', function () {
$cart = CheckoutCart::orderable();
$session = CheckoutCart::session($cart);

$this->withoutVite()->get(route('lunar.checkout.show', $session->uuid))
->assertOk()
->assertDontSee('dataLayer', false)
->assertDontSee('googletagmanager', false);
});
Loading
Loading