@aranova/tracking-react
v0.20.1
Published
React tracking and consent utilities for Aranova client sites
Readme
@aranova/tracking-react
React tracking and consent utilities for Aranova client sites.
Install
npm install @aranova/tracking-reactSetup
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, and the complete event metadata must fit the 4 KB limit; upload larger files separately and send their storage reference. 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.
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 .envSee the full guide: sales-tracking.md and the CLI reference: cli.md.
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 keyEach 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.
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
