@aranova/tracking-react
React tracking and consent utilities for Aranova client sites.
Install
npm install @aranova/tracking-react
Setup
Create one shared tracking module and import the scoped TrackingProvider / useTracking from that module throughout your app.
// src/lib/tracking.ts
import { createTracking } from "@aranova/tracking-react";
import { ARANOVA_TRACKING_CONFIG } from "./aranova-services";
export const { TrackingProvider, useTracking } = createTracking({
apiKey: import.meta.env.VITE_ARANOVA_TRACKING_API_KEY,
endpoint: import.meta.env.VITE_ARANOVA_TRACKING_ENDPOINT,
trackingConfig: ARANOVA_TRACKING_CONFIG,
triggers: {
automatic: {
page_view: {},
time_on_site: { thresholdSeconds: 60 },
specific_page_visit: {
pages: [
{ name: "contact_page", pathPattern: /^\/(contact|book|get-started)/ },
{ name: "google_ads_service_page", pathPattern: /^\/services\/google-ads\/?$/ },
],
},
},
manual: {
form_submit: {},
phone_click: {},
cta_click: {},
},
},
debug: import.meta.env.MODE !== "production",
});
Mount the provider at the root of your React tree.
// src/main.tsx
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { TrackingProvider } from "./lib/tracking";
createRoot(document.getElementById("root")!).render(
<TrackingProvider>
<App />
</TrackingProvider>,
);
Legacy static gtag IDs
gtagId, gtagIds, GoogleAdsTracking, and generated ARANOVA_GTAG_IDS remain
compatibility APIs. Do not use them for new installs: static IDs cannot consume dashboard
tag changes or tombstones without a redeploy. If maintaining a legacy install, gtagIds
loads every entry and takes precedence over gtagId.
<TrackingProvider gtagIds={{ production: "AW-111111111", test: "AW-222222222" }}>
<App />
</TrackingProvider>
Manual Events
Manual events must be registered under triggers.manual before trackEvent() accepts them.
import { useTracking } from "./lib/tracking";
export function LeadForm() {
const tracking = useTracking();
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
const form = event.currentTarget;
const data = new FormData(form);
tracking.trackEvent("form_submit", {
form: {
id: form.id,
action: form.getAttribute("action"),
fields: [
{
name: "service_interest",
type: "select",
label: "Service interest",
value: String(data.get("service_interest") ?? ""),
},
{
name: "is_existing_patient",
type: "checkbox",
label: "Existing patient",
value: data.get("is_existing_patient") === "on",
},
],
},
page: { path: window.location.pathname },
});
}
return (
<form id="lead-form" action="/api/lead" onSubmit={handleSubmit}>
{/* fields */}
</form>
);
}
fields[].value can be any JSON value: string, number, boolean, null, array, or object. Values must be JSON-serializable because events are stored as first-party JSONB. Intentionally submitted lead fields may include raw names, emails, phone numbers, addresses, selections, free-text messages, and submitted file data for first-party analytics and lead operations. Build the array explicitly from the submitted form; the SDK never scrapes arbitrary DOM fields. File/Blob objects must be converted to a JSON representation; upload larger files separately and send their storage reference. No byte limit is enforced on event metadata — but a submit fired just before navigation flushes over keepalive, which browsers cap near 64 KB and drop silently, so keep inlined payloads small. Never send passwords, authentication tokens, payment-card/bank credentials, or private keys. Apply the client's privacy notice, consent, retention, and regulated-data requirements. Google offline matching uses normalized, server-side SHA-256-hashed identifiers — not raw free-text/file metadata.
Phone clicks (tel: taps) — manual or auto-capture
Opt into auto-capture and every tel: link is tracked with no per-link code. Add
autoCapture to the phone_click registration — a single delegated click listener does the rest:
manual: {
phone_click: { autoCapture: {} }, // default selector: a[href^="tel:"]
// or narrow it: { autoCapture: { selector: "a.call" } }
},
<a href="tel:+14165550199" data-aranova-section="header">
(416) 555-0199
</a>
The number is read from the href (normalized to E.164); section comes from an optional
data-aranova-section. Unlike cta_click, a phone_click also fires the Google Ads
conversion for a linked phone-call goal (a CLICK_TO_CALL action) — auto-fired the moment the
tap is captured, so you never call trackConversion (firing needs the sales client wired with the
same config; see below).
Prefer to instrument links yourself? Fire it from a click handler — this fires the conversion the same way:
tracking.trackEvent("phone_click", {
phone_number: "+14165550199",
page: { path: window.location.pathname },
section: "header",
});
CTA clicks — manual or auto-capture
Fire cta_click yourself for full control over the name:
tracking.trackEvent("cta_click", {
cta_name: "Book appointment",
section: "hero",
destination_url: "/booking",
page: { path: window.location.pathname },
});
Or opt into auto-capture and skip per-button code. Add autoCapture to the
cta_click registration — this attaches a single delegated click listener:
manual: {
cta_click: { autoCapture: {} }, // default selector: [data-aranova-cta]
// or target existing classes: { autoCapture: { selector: "a.cta, .btn-primary" } }
},
Then tag your CTAs in markup — no imports, no handlers:
<a href="/booking" data-aranova-cta="Book now" data-aranova-section="hero">Book now</a>
<button data-aranova-cta="Get a quote">Get a quote</button>
cta_name resolves to the data-aranova-cta value, else the element's trimmed text
(capped 120 chars), else tag#id. section comes from an optional data-aranova-section;
href/destination_url and a short element descriptor are captured automatically. Point
the selector at real CTAs (an explicit attribute is the safe default) — every matching click
is counted, including ones that later stopPropagation.
Automatic dwell + exit (page_exit)
page_exit is captured automatically — no registration. On SPA navigation, tab hide, and
page unload it records the active (visible) time spent on the page plus the max scroll
depth reached, delivered via a keepalive beacon so the final page still counts. This powers
per-page dwell and drop-off in the dashboard Analytics tab; there is nothing to configure.
Typed lead forms
Generate the site's lead-form contract once and send submits through it, so a dropped required
field fails tsc instead of losing leads:
npx @aranova/tracking-cli forms gen # writes src/aranova-lead-forms.ts; `forms check` in CI
import { createLeadFormSubmit, slotFields } from "@aranova/tracking-react";
import { ARANOVA_LEAD_FORMS } from "@/aranova-lead-forms";
const leadForms = createLeadFormSubmit(useTracking(), ARANOVA_LEAD_FORMS);
leadForms.submit({
formId: "booking-form",
kind: "booking",
fields: { given_name: values.firstName, phone: values.phone, ...slotFields(values.slot) },
});
Development logs each contract violation, debug throws, production sends anyway tagged
form.contract_violations. A malformed hand-built form_submit is salvaged in production
(tagged invalid_shape) rather than dropped. Full guide:
docs/tracking-package/lead-forms.md.
Optional questions after the submit (patient type, timing, how they heard) attach to that same
lead. Keep the handle submit() returns and pass it to addDetails() from the thank-you screen.
That screen is a <section> with buttons, never a second <form>: a form logs its own
form_start (a form never submitted, in the funnel) and submits on Enter.
const { submission } = leadForms.submit({ formId: "booking-form", kind: "booking", fields });
setSubmission(submission); // then render the post-submit <section>
// "Add to my request" button's onClick: every role is optional here, nothing is owed.
leadForms.addDetails(submission, {
fields: { patient_type: answers.patientType, note: answers.note, email: answers.email },
extra: [{ key: "heard_from", label: "How did you hear about us?", value: answers.heardFrom }],
}); // { sent: false, reason: "no_fields" } when every answer is blank
Phone Fields
Bundled libphonenumber-js: parse/format utils + a React input. Display is configurable; the
value sent to the backend is always E.164. Configure once via createTracking({ phone: { defaultCountry: 'CA', display: 'national' } }).
import { usePhoneField, PhoneField, toE164 } from "@aranova/tracking-react";
const phone = usePhoneField(); // phone.value (display), phone.e164 (wire), .isValid, .error
<input {...phone.inputProps} />; // or the batteries-included <PhoneField name="phone" />
toE164("416-555-0199"); // '+14165550199' (null if invalid)
Pure, isomorphic utils are also at @aranova/tracking-react/phone (no React). Full guide:
phone.md.
Recording Sales
Record a sale / conversion (a first-class, mutable resource — not a fire-and-forget
event). Money is integer minor units (cents); currency is a required ISO-4217 enum.
One isomorphic createSalesClient (on the root entry) serves both sides — what a
key may do is enforced by the backend, not by hiding methods. Browser write with
your public key:
import { createSalesClient, toMinor } from "@aranova/tracking-react";
const sales = createSalesClient({
apiKey: import.meta.env.VITE_ARANOVA_TRACKING_API_KEY,
endpoint,
});
await sales.record({ currency: "CAD", amount_total_cents: toMinor(250, "CAD"), service: "tires" });
Reads / full CRUD require a secret key and must run server-side — never ship a
secret key in the browser bundle. In a Vite + Vercel app, hold it in a serverless
function. Import from the /sales subpath — it's React-free, so the server
bundle never pulls in the provider/components:
// api/sales.ts (Vercel serverless function — runs on the server)
import { createSalesClient } from "@aranova/tracking-react/sales";
const sales = createSalesClient({
apiKey: process.env.ARANOVA_TRACKING_SECRET_KEY!,
endpoint: process.env.ARANOVA_TRACKING_ENDPOINT!,
});
export default async function handler(_req, res) {
res.json(await sales.list({ limit: 50 }));
}
Secret-key reads power dashboards (all currency-grouped — never summed across currencies):
await sales.summary({ range: "mtd", timezone: "America/Toronto", compare_to: "previous_period" });
await sales.list({ sort: "amount_total_cents", want_total: true }); // → { items, total_count, … }
await sales.customers.list({ segment: "returning", sort: "total_spent" }); // phone-keyed roster
await sales.customers.summary({ range: "mtd" });
const cfg = await sales.business.config(); // tz / currencies / services (pk or sk)
summary() gains tz-aware calendar/custom ranges, granularity, and period-over-period
compare_to; legacy 24h/7d/30d are unchanged. A customer is their phone (E.164) — customers.*
is a live roster, no separate table. A public-key client calling any read gets a 403.
The root entry (@aranova/tracking-react) still re-exports createSalesClient and the
money/date helpers for back-compat, so existing imports keep working — but prefer /sales
in server code so the React surface never reaches your server bundle.
Generate the typed AranovaService union from your dashboard services with the CLI
(install it as a devDependency):
npm install --save-dev @aranova/tracking-cli
npx @aranova/tracking-cli gen # reads ARANOVA_TRACKING_SECRET_KEY from .env
See the full guide: sales-tracking.md and the CLI reference: cli.md.
Booking handoff (booking integrations)
When the business books through an integration (AutoLeap today), the published tracking config
carries integrations.autoleap.booking. After a lead form's successful submit (and after
form_submit fires), offer the optional "Pick your time" step:
import { BookingHandoffLink } from "@aranova/tracking-react";
import { ARANOVA_TRACKING_CONFIG } from "@/aranova-services";
<BookingHandoffLink
trackingConfig={ARANOVA_TRACKING_CONFIG}
fallbackUrl="https://myalp.io/…" // baked last resort when no config has loaded
prefill={{ firstName, lastName, phone, email }}
>
Pick your time
</BookingHandoffLink>;
- The link is the shop's own booking page from the live config, else
fallbackUrl; it renders nothing unless it ishttps://myalp.io/…. The phone goes out as digits. - It shows even when
enabledis false: the customer always has a way to book. useBookingIntegration(trackingConfig)returns the live provider (or null before a config loads), from the shared config runtime (no extra fetch):enabled,fallbackUrl,acceptsBookings,timezone,configVersion,rulesandoptions.getBookingIntegration(integrations)andbuildBookingHandoffUrl(url, prefill)are the React-free equivalents.- Reports the
booking_handoffcapability.
Website bookings (booking integrations)
When acceptsBookings is true the site can book straight into the shop's system through Aranova's
API. The SDK is headless; build the form in the site's own design system:
import { useBookingIntegration, useBookingSlots, useBookingSubmit } from "@aranova/tracking-react";
const booking = useBookingIntegration(ARANOVA_TRACKING_CONFIG);
const days = useBookingSlots(ARANOVA_TRACKING_CONFIG); // [{ date, weekday, morning, afternoon }]
const { status, outcome, submit } = useBookingSubmit(conversions.bookings, {
formSubmit: {
tracking,
formId: "booking-form",
// Your own questions, for our reporting only; never sent to the shop.
extraFields: [
{ name: "heardVia", type: "select", label: "How did you hear about us?", value: heardVia },
],
},
});
await submit({
startDate: "2026-10-06",
startHour: 9,
firstName,
lastName,
phone,
email,
vehicle: { year: 2019, make: "Toyota", model: "RAV4" },
dropOff: "dropOff", // only when booking.rules.dropOffOptions is non-empty
services: ["brakes"], // keys from booking.options
notes,
marketingSmsConsent, // the customer's own unchecked opt-in; never default it on
});
conversionsis the site's onecreateSalesClient({ apiKey, endpoint, firing: getTrackingConfigRuntime(...) });.bookingsreads the rules from that runtime.outcome.status:created(the shop texts and emails the customer itself; send nothing of your own),pending(same success; still being sent, the owner is alerted if it fails;statusturnspendingas soon as the server says so, while the final answer is polled),failed(success copy plusoutcome.fallbackUrl, the prefilled shop page),invalid(outcome.message, nothing sent),stale(the times changed; the config is refetched).outcome.mode(andbooking.modebefore submitting):livefor every real customer;test(sent to the shop's test lead source) orsimulated(never sent) on a test visit or a non-production API. Show a small "test booking" note when it isn'tlive.- Development only:
useBookingSubmit(bookings, { simulate: "failed" })forces a simulated outcome (created,rejected,unknown,failed) to exercise each screen. The production API ignores it. Test locally against the local API and the local CDN; the SDK warns in the console when the endpoint is localhost but the config comes from production. booking.optionsarrive grouped by the shop's service category: each hasgroup(the heading, ornull) and an optionaldescription.groupBookingOptions(booking.options)returns[{ group, options }]in order, so render one heading per group with its choices under it.useBookingSlots/bookableSlots(rules, { timeZone })offer exactly the portal's start hours, judged by the shop's date.useBookingSubmitreuses one attempt key across a double-click or a retry, and sends the visit'sform_submitonce: don't send your own for this form. It is a typedbookingsubmit (ADR-0024): every field carries its contractrole,serviceis the option keys,preferred_dateisYYYY-MM-DD,preferred_timeis{ start: "HH:00", end: null }, anddrop_offiswait-for-it/drop-off. Add the form's own questions withformSubmit.extraFields; a name the booking already reports (phone,email, ...) is dropped.- Ask for the last name separately, and a mobile number (the shop's confirmation text goes to it).
- Details after booking (when
booking.rules.vehicleDeferred, for extra notes, or for the site's own "How did you hear about us?" answer):updateBooking({ vehicle, notes, heardVia })from the same hook. It resolvesupdated/queued/notified_owner(all saved) orinvalid,mismatch,conflict,not_found,failed(onlyfailedis worth a retry). To survive a reload, savebooked({ bookingId, attemptKey }) insessionStorageand pass it back:updateBooking(details, saved).heardViais reporting-only and never reaches the shop; it takes the same values as the form'sheardViaform_submitfield, so asking it on the success screen instead of the form reports the same. One update per booking, so send it with a deferred vehicle rather than in a second call. - Reports the
booking_integrationcapability.
On-site conversion firing
Run tracking-cli gen once to emit ARANOVA_TRACKING_CONFIG, then use that
reference everywhere Google tracking is initialized. R2 is authoritative: the SDK confirms
the current object before emitting Google tag, page-view, or conversion commands. Dashboard
tag changes, goal remaps, and disabled-state tombstones propagate under
public, max-age=60, must-revalidate with no CLI run or site redeploy.
import {
createSalesClient,
createTracking,
getTrackingConfigRuntime,
toMinor,
} from "@aranova/tracking-react";
import {
ARANOVA_TRACKING_CONFIG,
type AranovaConversion,
type AranovaService,
} from "./aranova-services";
const googleTracking = getTrackingConfigRuntime(ARANOVA_TRACKING_CONFIG);
export const { TrackingProvider, useTracking } = createTracking({
apiKey,
endpoint,
triggers,
trackingConfig: ARANOVA_TRACKING_CONFIG,
});
// Bind the generated unions so `service` and `trackConversion` keys are type-checked.
const sales = createSalesClient<AranovaService, AranovaConversion>({
apiKey,
endpoint,
firing: googleTracking,
});
await sales.recordSale({
currency: "CAD",
amount_total_cents: toMinor(250, "CAD"),
service: "tires",
}); // records + fires
- Automatic event-goals fire when their registered detector crosses the published threshold.
- Sales + manual goals use the shared runtime above. Firing is consent-gated, transaction-deduped, and browser-only.
Manual conversions (multiple forms): register the trigger once, then call trackConversion(key) in each form's submit handler. Keys come from the dashboard Goals tab (event-goals with a form_submit/phone_click/cta_click trigger mapped to a WEBPAGE action) and are emitted as the AranovaConversion union by gen — so they autocomplete and reject typos:
// createTracking({ …, triggers: { manual: { form_submit: {} } } }) // register once
// in the contact form's onSubmit:
sales.trackConversion("contact_form"); // ✅ typed by AranovaConversion
// in the demo form's onSubmit:
sales.trackConversion("demo_request");
sales.trackConversion("typo"); // ❌ compile error — not a known goal key
Each call fires only the WEBPAGE conversion for that goal (no /sales write); the SDK
no-ops if the goal isn't mapped. Remapping an existing key needs no codegen. A new manual
goal key still requires tracking-cli gen to refresh AranovaConversion, plus site handler
wiring. ARANOVA_CONFIG_URL and conversionConfig: { cdnUrl } remain legacy compatibility
APIs; do not use them for new installs. See
conversion-config-schema.md.
ARANOVA_TRACKING_CONFIG carries the business identity and the SDK composes the object URL
against the production CDN — the endpoint option only moves event ingest, not the config
fetch. To read the config from elsewhere (a local object store), set
REACT_APP_ARANOVA_CDN_BASE_URL; the generated module already reads it and falls back to
production, so no code change is needed. Leave it unset in production.
Before any manual goal exists,
genemits an emptyARANOVA_CONVERSIONS, soAranovaConversionresolves tonever— binding it (createSalesClient<AranovaService, AranovaConversion>) then makes everytrackConversioncall a compile error. Until you've mapped at least one manual goal and re-rungen, bind only the service generic (createSalesClient<AranovaService>(…)); the conversion key defaults back tostring.
Consent (v2 — opt-out model)
Tracking is on by default: gtag, the Meta Pixel, and on-site conversion firing all run from first paint unless the visitor has stored an explicit, unexpired decline. A decline is honored for 90 days (then the visitor reverts to default-granted); an explicit grant never expires. There is no pending state and the packages ship no consent UI — each site provides its own footer "cookie preferences" control and discloses tracking in its privacy policy (that notice is what makes the opt-out model defensible).
Footer control — useCookiePreferences()
import { useCookiePreferences } from "@aranova/tracking-react";
function CookiePreferences() {
const { isDenied, isDefault, optOut, optIn } = useCookiePreferences();
return isDenied ? (
<button onClick={optIn}>Enable ad measurement</button>
) : (
<button onClick={optOut}>Opt out of ad measurement</button>
);
}
The hook returns the effective state ("granted" | "denied"), its source ("default" = no explicit choice, "explicit"), boolean helpers (isDefault / isGranted / isDenied), the choice's updatedAt / expiresAt, and the optOut() / optIn() / reset() actions. It stays in sync with other components in the same tab (via onConsentChange) and other tabs (via storage events). optOut accepts a custom TTL via the hook's { declineTtlDays } option.
For non-component contexts the same primitives are exported as plain functions: optOut(), optIn(), resetConsent(), getConsentChoice(), and onConsentChange().
Deprecated opt-in flow
<ConsentBanner />, useConsent(), and useConsentState() are @deprecated and scheduled for removal. The banner is permanently inert — it rendered only while consent was pending, which no longer occurs — so leaving it mounted is harmless but pointless. useConsent() still works as a shim (isPending is always false; accept/decline map to optIn/optOut).
Migrating from the banner flow: delete <ConsentBanner />, add a footer control built on useCookiePreferences, and mention the tracking + opt-out in your privacy policy. Existing visitors' stored grants stay granted; stored declines stay denied for 90 days from their first visit after the upgrade.
Exports
createTracking()TrackingProviderand scopeduseTrackingAdPlatformTrackingandgetTrackingConfigRuntime; legacyGoogleAdsTracking- Consent (v2):
useCookiePreferences()+UseCookiePreferencesResult; standaloneoptIn(),optOut(),resetConsent(),getConsentChoice(),onConsentChange(),getConsentState(),setConsentState() - Deprecated consent shims:
ConsentBanner+ConsentBannerProps,useConsent()+UseConsentResult,useConsentState() - Attribution hooks:
useTrackingParams(),useGclid() createSalesClient()(isomorphic — public key writes; secret key reads/CRUD,summary,customers.*,business.config) + money/date helpers (toMinor/fromMinor/formatMoney/formatDateInTz). Also available React-free at@aranova/tracking-react/sales(with all sale/customer/config types) — the recommended import for server/serverless code.- Phone:
parsePhone/toE164/formatPhone/formatPhoneAsTyped/phoneField,usePhoneField,PhoneField(utils also at/phone) - Codegen:
@aranova/tracking-cli—gentyped service unions (devDependency) - Event metadata/config types such as
FormSubmitMetadata,PhoneClickMetadata, andJsonValue