Skip to content
13 changes: 13 additions & 0 deletions assets/hmp-commerce.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@
const result = await response.json();
if (
result.schema_version !== 'registration.response.v1' ||
result.registration_status !== 'REGISTERED' ||
!REGISTRATION_ID_PATTERN.test(result.registration_id || '') ||
!STATUS_TOKEN_PATTERN.test(result.commerce_status_token || '') ||
!validCheckoutUrl(result.checkout && result.checkout.widget_url, payload, result.checkout)
Expand All @@ -255,6 +256,18 @@
session_code: payload.session_code
}));
storage().removeItem(IDEMPOTENCY_KEY);
const completedRegistration = Object.freeze({
registrationId: result.registration_id,
sessionCode: payload.session_code
});
window.__hbCompletedRegistration = completedRegistration;
if (typeof window.CustomEvent === 'function' && typeof window.dispatchEvent === 'function') {
try {
window.dispatchEvent(new window.CustomEvent('hb:registration-completed', {
detail: completedRegistration
}));
} catch (_) {}
}
return result;
}

Expand Down
51 changes: 50 additions & 1 deletion assets/meta-pixel.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
const CONSENT_KEY = 'hb-meta-consent';
const GRANTED = 'granted';
const DENIED = 'denied';
const REGISTRATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SESSION_CODE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const sentRegistrationIds = new Set();
let pixelLoaded = false;

const readConsent = () => {
Expand Down Expand Up @@ -48,7 +51,12 @@
};

function loadPixel() {
if (pixelLoaded || readConsent() !== GRANTED) return;
if (readConsent() !== GRANTED) return;
if (pixelLoaded) {
if (typeof window.fbq === 'function') window.fbq('consent', 'grant');
if (window.__hbCompletedRegistration) trackCompletedRegistration(window.__hbCompletedRegistration);
return;
}
pixelLoaded = true;

/* Meta Pixel base code */
Expand All @@ -64,12 +72,53 @@
window.fbq('consent', 'grant');
window.fbq('init', PIXEL_ID);
window.fbq('track', 'PageView');
if (window.__hbCompletedRegistration) trackCompletedRegistration(window.__hbCompletedRegistration);
}

function trackCompletedRegistration(detail) {
if (
readConsent() !== GRANTED ||
typeof window.fbq !== 'function' ||
!detail ||
!REGISTRATION_ID_PATTERN.test(detail.registrationId || '') ||
!SESSION_CODE_PATTERN.test(detail.sessionCode || '')
) return;
const registrationKey = `hb-meta-registration-${detail.registrationId}`;
if (sentRegistrationIds.has(detail.registrationId)) return;
let sentInCurrentTab = false;
try {
sentInCurrentTab = Boolean(sessionStorage.getItem(registrationKey));
} catch (_) {}
try {
if (localStorage.getItem(registrationKey)) return;
if (sentInCurrentTab) localStorage.setItem(registrationKey, 'sent');
} catch (_) {}
if (sentInCurrentTab) {
return;
}
for (const browserStorage of [sessionStorage, localStorage]) {
try {
browserStorage.setItem(registrationKey, 'sent');
} catch (_) {}
}
sentRegistrationIds.add(detail.registrationId);

window.fbq('track', 'CompleteRegistration', {
content_name: 'Harmonic Myth Projection',
content_ids: [detail.sessionCode],
content_type: 'product'
});
}

// Purchase intentionally fails closed until commerce-status exposes an
// authoritative paid conversion, real amount/currency and stable opaque ID.
// registrationId + ACCESS_READY alone can also represent a zero-value access.

window.addEventListener('hb:registration-completed', (event) => {
window.__hbCompletedRegistration = event.detail;
if (pixelLoaded) trackCompletedRegistration(event.detail);
});

function revokePixel() {
if (typeof window.fbq === 'function') window.fbq('consent', 'revoke');
['_fbp', '_fbc'].forEach((name) => {
Expand Down
106 changes: 105 additions & 1 deletion tests/hmp-commerce.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ function runtime(fetchImpl, overrides = {}, registrationOpen = true) {
fetch: fetchImpl,
sessionStorage,
setTimeout,
CustomEvent: class CustomEvent {
constructor(type, options = {}) {
this.type = type;
this.detail = options.detail;
}
},
...overrides
};
const context = vm.createContext({URL, window});
Expand All @@ -33,7 +39,7 @@ function runtime(fetchImpl, overrides = {}, registrationOpen = true) {
? source
: source.replace('const REGISTRATION_OPEN = true;', 'const REGISTRATION_OPEN = false;');
vm.runInContext(evaluatedSource, context);
return {api: window.HMPCommerce, values};
return {api: window.HMPCommerce, values, window};
}

function widgetDom() {
Expand Down Expand Up @@ -258,6 +264,7 @@ test('stores a separate status token only after a valid registration response',
ok: true,
json: async () => ({
schema_version: 'registration.response.v1',
registration_status: 'REGISTERED',
registration_id: '20000000-0000-4000-8000-000000000001',
commerce_status_token: statusToken,
checkout
Expand All @@ -274,6 +281,103 @@ test('stores a separate status token only after a valid registration response',
assert.match(result.checkout.widget_url, /^https:\/\/tickets\.harmonicbeacon\.com\//);
});

test('announces CompleteRegistration only after a canonical REGISTERED response', async () => {
const events = [];
const {api, window} = runtime(async () => ({
ok: true,
json: async () => ({
schema_version: 'registration.response.v1',
registration_status: 'REGISTERED',
registration_id: '20000000-0000-4000-8000-000000000001',
commerce_status_token: statusToken,
checkout
})
}), {
dispatchEvent: event => { events.push(event); return true; }
});

await api.register(payload);

assert.equal(events.length, 1);
assert.equal(events[0].type, 'hb:registration-completed');
assert.deepEqual(
JSON.parse(JSON.stringify(events[0].detail)),
{
registrationId: '20000000-0000-4000-8000-000000000001',
sessionCode: 'es-0830-cr'
}
);
assert.equal(window.__hbCompletedRegistration.registrationId, events[0].detail.registrationId);
});

test('an analytics listener failure never rejects a valid registration response', async () => {
const {api} = runtime(async () => ({
ok: true,
json: async () => ({
schema_version: 'registration.response.v1',
registration_status: 'REGISTERED',
registration_id: '20000000-0000-4000-8000-000000000001',
commerce_status_token: statusToken,
checkout
})
}), {
dispatchEvent: () => { throw new Error('analytics unavailable'); }
});

const result = await api.register(payload);

assert.equal(result.registration_status, 'REGISTERED');
assert.equal(result.checkout.widget_url, checkout.widget_url);
});

test('an analytics listener failure never blocks the validated checkout widget', async () => {
const dom = widgetDom();
const {api} = runtime(async () => ({
ok: true,
json: async () => ({
schema_version: 'registration.response.v1',
registration_status: 'REGISTERED',
registration_id: '20000000-0000-4000-8000-000000000001',
commerce_status_token: statusToken,
checkout
})
}), {
document: dom.document,
dispatchEvent: () => { throw new Error('analytics unavailable'); }
});

const result = await api.register(payload);
const script = api.mountCheckoutWidget(dom.container, payload, result.checkout);
const mountedUrl = new URL(script.attributes.get('data-url'));
const mountedHash = new URLSearchParams(mountedUrl.hash.slice(1));

assert.equal(dom.container.children.length, 1);
assert.equal(script.src, 'https://cdn.tickettailor.com/js/widgets/min/widget.js');
assert.equal(mountedHash.get('p[meta_registration_context]'), checkoutContext);
assert.equal(mountedHash.get('p[first_name]'), payload.first_name);
assert.equal(mountedHash.get('p[last_name]'), payload.last_name);
assert.equal(mountedHash.get('p[email]'), payload.email);
});

test('rejects a response without canonical REGISTERED status and emits no event', async () => {
const events = [];
const {api} = runtime(async () => ({
ok: true,
json: async () => ({
schema_version: 'registration.response.v1',
registration_status: 'VERIFYING',
registration_id: '20000000-0000-4000-8000-000000000001',
commerce_status_token: statusToken,
checkout
})
}), {
dispatchEvent: event => { events.push(event); return true; }
});

await assert.rejects(api.register(payload), /invalid_registration_response/);
assert.equal(events.length, 0);
});

test('rejects malformed registration ids and status tokens before storing context', async () => {
const malformed = async () => ({
ok: true,
Expand Down
Loading