@aranova/tracking-react

中文文档
1.21.0发布于 2026/11/07TypeScriptESM + CJS近 30 天下载 2069

React tracking and consent utilities for Aranova client sites

原始使用文档
文档目录17 节
  1. Install
  2. Setup
  3. Legacy static gtag IDs
  4. Manual Events
  5. Phone clicks ( tel: taps) — manual or auto-capture
  6. CTA clicks — manual or auto-capture
  7. Automatic dwell + exit ( page_exit )
  8. Typed lead forms
  9. Phone Fields
  10. Recording Sales
  11. Booking handoff (booking integrations)
  12. Website bookings (booking integrations)
  13. On-site conversion firing
  14. Consent (v2 — opt-out model)
  15. Footer control — useCookiePreferences()
  16. Deprecated opt-in flow
  17. Exports

@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 is https://myalp.io/…. The phone goes out as digits.
  • It shows even when enabled is 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, rules and options. getBookingIntegration(integrations) and buildBookingHandoffUrl(url, prefill) are the React-free equivalents.
  • Reports the booking_handoff capability.

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
});
  • conversions is the site's one createSalesClient({ apiKey, endpoint, firing: getTrackingConfigRuntime(...) }); .bookings reads 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; status turns pending as soon as the server says so, while the final answer is polled), failed (success copy plus outcome.fallbackUrl, the prefilled shop page), invalid (outcome.message, nothing sent), stale (the times changed; the config is refetched).
  • outcome.mode (and booking.mode before submitting): live for every real customer; test (sent to the shop's test lead source) or simulated (never sent) on a test visit or a non-production API. Show a small "test booking" note when it isn't live.
  • 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.options arrive grouped by the shop's service category: each has group (the heading, or null) and an optional description. 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. useBookingSubmit reuses one attempt key across a double-click or a retry, and sends the visit's form_submit once: don't send your own for this form. It is a typed booking submit (ADR-0024): every field carries its contract role, service is the option keys, preferred_date is YYYY-MM-DD, preferred_time is { start: "HH:00", end: null }, and drop_off is wait-for-it / drop-off. Add the form's own questions with formSubmit.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 resolves updated / queued / notified_owner (all saved) or invalid, mismatch, conflict, not_found, failed (only failed is worth a retry). To survive a reload, save booked ({ bookingId, attemptKey }) in sessionStorage and pass it back: updateBooking(details, saved). heardVia is reporting-only and never reaches the shop; it takes the same values as the form's heardVia form_submit field, 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_integration capability.

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, gen emits an empty ARANOVA_CONVERSIONS, so AranovaConversion resolves to never — binding it (createSalesClient<AranovaService, AranovaConversion>) then makes every trackConversion call a compile error. Until you've mapped at least one manual goal and re-run gen, bind only the service generic (createSalesClient<AranovaService>(…)); the conversion key defaults back to string.

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).

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()
  • TrackingProvider and scoped useTracking
  • AdPlatformTracking and getTrackingConfigRuntime; legacy GoogleAdsTracking
  • Consent (v2): useCookiePreferences() + UseCookiePreferencesResult; standalone optIn(), 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 — gen typed service unions (devDependency)
  • Event metadata/config types such as FormSubmitMetadata, PhoneClickMetadata, and JsonValue

版本与发布记录

@aranova/tracking-react 共发布 84 个版本,首次发布于 2026/04/10;过去 12 个月发布了 84 个版本。

版本发布日期距今
1.21.0latest2026/11/07今天
1.20.02026/11/07今天
1.19.02026/10/308 天前
1.18.02026/10/2117 天前
1.17.02026/10/2018 天前
1.16.12026/10/1919 天前

关于 @aranova/tracking-react 的常见问题

@aranova/tracking-react 是什么?

React tracking and consent utilities for Aranova client sites

如何安装 @aranova/tracking-react?

在项目目录运行 npm install @aranova/tracking-react,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(>=18.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@aranova/tracking-react 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@aranova/tracking-react 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@aranova/tracking-react 可以商用吗?

@aranova/tracking-react 使用 未声明 许可。package.json 没有声明许可证。没有许可证的代码默认保留全部权利,商用前请联系作者或查看仓库中的 LICENSE 文件。以上为概括,不构成法律意见,请以许可原文为准。

@aranova/tracking-react 的下载量有多少?

根据 npm 官方下载统计,@aranova/tracking-react 在 2026/10/07 – 2026/11/05 这 30 天内被下载 2,069 次,日均约 69 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@aranova/tracking-react 还在维护吗?

最新版本 1.21.0 发布于 2026/11/07(今天);至今共发布 84 个版本,其中过去 12 个月发布了 84 个;首次发布于 2026/04/10。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。