Analytics
The analytics feature exposes a provider and hook utilities that let the search embed collect events across multiple analytics services (Mixpanel and PostHog) while keeping visitor identity in sync with the IndexedDB ORM. Everything is exported from @superbright/indexeddb-orm.
import { AnalyticsProvider, useAnalytics } from "@superbright/indexeddb-orm";
Example setup with visitor UUID resolved before the provider mounts:
import { QueryClientProvider } from "@tanstack/react-query";
import { AnalyticsProvider } from "@superbright/indexeddb-orm";
import { useVisitorSession } from "./hooks/useVisitorSession";
import { store } from "@superbright/indexeddb-orm";
// ── Outer shell – creates the QueryClient, cannot call hooks ──────────────
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<InnerProviders>{children}</InnerProviders>
</QueryClientProvider>
);
}
// ── Inner shell – lives inside QueryClientProvider, can call hooks ────────
function InnerProviders({ children }: { children: React.ReactNode }) {
// Reads visitor_uuid from IndexedDB; fetches from API and stores it if absent
const visitor_uuid = useVisitorSession(getSlugFromPathname());
return (
<AnalyticsProvider
visitor_uuid={visitor_uuid}
mixpanelToken={import.meta.env.VITE_MIXPANEL_TOKEN}
posthogKey={import.meta.env.VITE_POSTHOG_KEY}
posthogHost={import.meta.env.VITE_POSTHOG_HOST}
envMode={import.meta.env.MODE ?? "production"}
>
{children}
</AnalyticsProvider>
);
}
Getting started
- Wrap your React tree with
AnalyticsProviderinside a component that has access to TanStack Query (i.e. insideQueryClientProvider). - Resolve the visitor UUID before rendering by calling
useVisitorSession(orstore.getVisitorUUID()directly) and pass the result as thevisitor_uuidprop. The provider uses this to callidentify()on every configured service. Analytics initialisation is deferred untilvisitor_uuidis truthy, so no events are attributed to an anonymous session. - Pass whichever service credentials you need — both are optional. If only
mixpanelTokenis provided, only Mixpanel is initialised; if onlyposthogKeyis provided, only PostHog is initialised; both can run simultaneously. - Call
useTrackingEventsoruseTrackEventanywhere inside the provider to send events. Each call fans out to every configured service automatically. - Reuse the exported interfaces when building strongly typed payloads.
All tracking helpers run payloads through Zod validators. Invalid payloads are silently dropped in production and logged as console.warn in development.
AnalyticsProvider
Component
React context provider that initialises Mixpanel and/or PostHog, identifies the current visitor on each service, and exposes a unified tracking interface through context. Debug logging is enabled when the environment is set to development.
Props
| Prop | Type | Description |
|---|---|---|
children | React.ReactNode | React subtree that can consume the analytics context. |
visitor_uuid | string | undefined | Stable visitor UUID sourced from IndexedDB (via store.getVisitorUUID() or useVisitorSession). Both Mixpanel and PostHog call identify() with this value once it is available. Analytics initialisation is deferred until this prop is truthy. |
mixpanelToken | string | undefined | Mixpanel project token. When omitted, Mixpanel is not initialised. |
posthogKey | string | undefined | PostHog project API key. When omitted, PostHog is not initialised. |
posthogHost | string | undefined | PostHog API host. Defaults to https://us.i.posthog.com when not provided. |
envMode | string | undefined | Optional environment hint. "development" and "staging" log errors when tokens are missing; other values warn. Debug mode only enables during development. |
Peer dependencies
Both analytics SDKs are optional peer dependencies. Install only the ones you need:
# Mixpanel only
pnpm add mixpanel-browser
# PostHog only
pnpm add posthog-js
# Both
pnpm add mixpanel-browser posthog-js
Hooks
useAnalytics
Returns the unified analytics client from context. The client exposes track and identify methods that fan out to every configured service.
const analytics = useAnalytics();
analytics.track("Page View", { page: "/search" });
analytics.identify("user-uuid");
useTrackEvent
Returns a callback that forwards eventName and optional data to all configured services. Useful for ad-hoc events.
const trackEvent = useTrackEvent();
trackEvent("CTA Click", { propertyId: "123", propertySlug: "eastside" });
useTrackingEvents
Provides a typed catalogue of high-level event helpers used throughout the search experience.
All payload properties are defined in camelCase for ergonomic TypeScript usage. The tracking layer automatically converts keys to snake_case before dispatching events, so downstream dashboards continue to receive the expected field names.
Internally, payloads are normalised to camelCase for Zod validation, then converted to snake_case before dispatch. Both snake_case and camelCase inputs are accepted.
| Helper | Description | Payload type |
|---|---|---|
trackViewAllUnits | Fires when the user expands to see all units. | ViewAllUnitsEvent |
trackQuestionnaireSkip | Records skipping a questionnaire step. | QuestionnaireEvent |
trackQuestionnaireContinue | Records continuing a questionnaire step and selected answers. | ContinueQuestionnaireEvent |
trackQuestionnaireBack | Records navigating back in the questionnaire. | QuestionnaireEvent |
trackClickUnit | Tracks a unit card click in search results. | ClickUnitEvent |
trackHoverUnit | Tracks hovering a unit card. | HoverUnitEvent |
trackLoadMore | Fired when the results pagination loads additional units. | LoadMoreEvent |
trackFilterOpened | Logged when the filter drawer/modal opens. | FilterOpenEvent |
trackFilterCanceled | Fired after filters are cleared/cancelled. | FilterEvent |
trackFilterApplied | Fired when filters are confirmed. | FilterEvent |
trackOpenSort | Logged when the sort control is opened. | SortOpenEvent |
trackSelectSort | Records a chosen sort option. | SortEvent |
trackCancelSort | Records cancelling the sort selection. | SortEvent |
trackClickUnitFavorites | Tracks interactions within the favorites list. | ClickUnitFavoritesEvent |
trackUnitUnfavorited | Fires when a unit is removed from favorites. | UnitUnfavoritedEvent |
trackQuestionnaireResult | Tracks the completion state of the questionnaire. | QuestionnaireResultEvent |
trackSimilarUnitsImpression | Fires when similar units data is available and the UI renders the feature (API success + component visible). Only triggered when the user lands on 360 without an existing search query. | SimilarUnitsImpressionEvent |
trackSimilarUnitClick | Fires when a user clicks a unit from the similar units list. | SimilarUnitClickEvent |
trackSimilarUnitJumpSuccess | Fires when navigation to the selected unit completes successfully (new unit loads or 360 content is replaced). | SimilarUnitJumpSuccessEvent |
trackSimilarUnitJumpError | Fires when navigation fails or the user interacts before the system is ready (API not loaded, race condition, navigation error). | SimilarUnitJumpErrorEvent |
Functions
createStrictTracker
Low-level factory used internally by buildTrackingEvents. Creates a single typed tracker for a given event name and Zod schema.
createStrictTracker("My Event", mySchema)
Deprecated:
skipKeyTransformhas been removed. It was a no-op becauseconvertKeysToSnakeCaseis idempotent for snake_case inputs.
buildTrackingEvents
Creates the same catalogue of tracking helpers that useTrackingEvents returns, but accepts any compatible TrackEventFn. This is useful for testing or environments where React hooks are unavailable.
import { buildTrackingEvents } from "@superbright/indexeddb-orm";
const trackSpy = (event: string, data?: Record<string, unknown>) => {
console.log(event, data);
};
const analytics = buildTrackingEvents(trackSpy);
analytics.trackSelectSort({
propertyId: "123",
propertySlug: "main-st",
sortIndex: 0,
sortEnum: "relevance",
});
generateUserUUID
generateUserUUID was the previous mechanism used internally by AnalyticsProvider to resolve visitor identity. The provider no longer calls it automatically. Pass visitor_uuid as a prop instead — see Visitor UUID below.
Visitor UUID
The ORM stores a stable visitor identifier under the "user" key in IndexedDB, nested as { visitor_uuid: "…" }. Two methods on the store singleton manage it directly.
import { store } from "@superbright/indexeddb-orm";
store.getVisitorUUID
const uuid = await store.getVisitorUUID();
// → string | null
Reads visitor_uuid from the "user" KV entry. Returns null when no UUID has been stored yet.
store.setVisitorUUID
await store.setVisitorUUID("550e8400-e29b-41d4-a716-446655440000");
Persists visitor_uuid inside the "user" KV entry, merging with any other fields already stored there.
useVisitorSession (recommended)
For React apps, use the useVisitorSession hook instead of calling the store methods directly. It chains two TanStack Query queries:
- Read — calls
store.getVisitorUUID()to check IndexedDB. - Fetch — if the read returns
null, callsPOST /embed/{slug}/sessionto obtain a fresh UUID from the API and stores it viastore.setVisitorUUID().
The resolved UUID (or undefined while loading) is returned and should be passed straight to AnalyticsProvider as visitor_uuid.
import { useQuery } from "@tanstack/react-query";
import { store } from "@superbright/indexeddb-orm";
export const useVisitorSession = (slug: string | undefined) => {
const storedQuery = useQuery({
queryKey: ["visitor_uuid"],
queryFn: () => store.getVisitorUUID(),
staleTime: Infinity,
cacheTime: Infinity,
});
const sessionQuery = useQuery({
queryKey: ["visitor-session", slug],
queryFn: async () => {
const response = await httpService.post(`embed/${slug}/session`, {});
const vid = response.data.visitor_uuid;
await store.setVisitorUUID(vid);
return vid;
},
// Only fetch from the API when IndexedDB confirmed no UUID is stored
enabled: !!slug && storedQuery.isSuccess && storedQuery.data === null,
staleTime: Infinity,
cacheTime: Infinity,
});
return storedQuery.data ?? sessionQuery.data ?? undefined;
};
Important:
useVisitorSessioncallsuseQuery, so it must be rendered inside aQueryClientProvider. Split your providers into an outer shell (createsQueryClientProvider) and an inner shell (calls the hook and rendersAnalyticsProvider). See the Getting started example above.
Interfaces
TrackEventFn
Function signature consumed by buildTrackingEvents. Receives the event name and the (optionally) parsed payload.
| Parameter | Type | Description |
|---|---|---|
eventName | string | Fully qualified event key. |
data | Record<string, any> | undefined | Optional payload that has already been validated by Zod schemas. |
BaseEvent
| Property | Type | Description |
|---|---|---|
propertyId | string | Identifier of the property a user is viewing (stringified). |
propertySlug | string | Slug used to represent the property in URLs or analytics. |
ViewAllUnitsEvent
Extends BaseEvent.
| Property | Type | Description |
|---|---|---|
propertyName | string | Human-friendly property name shown to renters. |
totalUnitsAvailable | number | Total units currently available at the property. |
resultsUnitCount | number | Count of units returned in the latest results set. |
unitCountDate | string | ISO timestamp when the counts were captured. |
LoadMoreEvent
Extends BaseEvent.
| Property | Type | Description |
|---|---|---|
propertyName | string | Human-friendly property name shown to renters. |
totalUnitsAvailable | number | Total units currently available at the property. |
resultsUnitCount | number | Count of units currently displayed in the results list. |
unitCountDate | string | ISO timestamp when the counts were captured. |
QuestionnaireEvent
Extends BaseEvent.
| Property | Type | Description |
|---|---|---|
questionIndex | number | Zero-based index of the question displayed to the visitor. |
questionEnum | QuestionEnum | Normalized question category. |
ContinueQuestionnaireEvent
Extends QuestionnaireEvent.
| Property | Type | Description |
|---|---|---|
selectedAnswersEnumArray | string[] | All answer identifiers chosen for the current step. |
UnitEvent
Extends BaseEvent.
| Property | Type | Description |
|---|---|---|
unitId | string | Unique identifier for the unit. |
unitTitle | string | Human-readable unit title. |
unitSlug | string | Slug representing the unit in URLs/analytics. |
ClickUnitEvent
Extends BaseEvent.
| Property | Type | Description |
|---|---|---|
propertyName | string | Friendly property name displayed to renters. |
unitId | string | Unique identifier for the unit. |
unitTitle | string | Human-readable unit title. |
unitSlug | string | Slug representing the unit in URLs/analytics. |
favoritedUnit | boolean | Whether the unit is favorited at the moment of the click. |
HoverUnitEvent
Extends BaseEvent.
| Property | Type | Description |
|---|---|---|
propertyName | string | Friendly property name displayed to renters. |
unitId | string | Unique identifier for the unit. |
unitTitle | string | Human-readable unit title. |
unitSlug | string | Slug representing the unit being hovered. |
favoritedUnit | boolean | Whether the unit is favorited at the moment of the hover. |
ClickUnitFavoritesEvent
Extends ClickUnitEvent.
| Property | Type | Description |
|---|---|---|
favoritedUnit | boolean | Indicates whether the unit is favorited after the interaction. |
FilterEvent
Extends BaseEvent.
| Property | Type | Description |
|---|---|---|
propertyName | string | Human-friendly property name shown to renters. |
filterIndex | number | Position of the filter in the UI. |
filterLabel | string | Display label for the filter group. |
selectedFiltersEnum | string[] | Values applied when the event fires (coerced to strings). |
FilterOpenEvent
Extends BaseEvent.
| Property | Type | Description |
|---|---|---|
propertyName | string | Human-friendly property name shown to renters. |
filterIndex | number | Position of the filter in the UI. |
filterLabel | string | Display label for the filter. |
selectedFiltersEnum | string[] | Current selection when the filter is opened. |
filterName | string | Internal filter key that was opened. |
SortEvent
Extends BaseEvent.
| Property | Type | Description |
|---|---|---|
sortIndex | number | Index of the selected sort option. |
sortEnum | SortEnum | Sort option identifier. |
SortOpenEvent
Extends BaseEvent.
| Property | Type | Description |
|---|---|---|
propertyName | string | Human-friendly property name shown to renters. |
sortIndex | number | Index of the selected sort option. |
sortEnum | SortEnum | Sort option identifier. |
resultsUnitCount | number | Count of units currently visible when the sort panel opens. |
unitCountDate | string | ISO timestamp captured when the sort panel opens. |
QuestionnaireResultEvent
Extends BaseEvent.
| Property | Type | Description |
|---|---|---|
fullQuestionnaireSchemaJson | unknown | Raw questionnaire payload persisted for debugging/analysis. |
unitResults | { type?: string; total?: number; units?: { unitId: string; unit_name?: string }[] } | Optional summary of the units returned after questionnaire completion. |
TrackingEventWithUnits
Extends FilterEvent.
Adds a trackingEvent callback so consumers can replay the original filter event once unit data is available. Extra metadata may be attached (the type allows arbitrary keys) for downstream processing.
| Property | Type | Description |
|---|---|---|
trackingEvent | (payload: FilterEvent) => void | Deferred analytics callback that should be invoked once unit data is available. |
[metadata: string] | unknown | Optional additional metadata forwarded with the deferred event. |
Enumerations
QuestionEnum
Represents the questionnaire steps that emit analytics events.
| Value | Description |
|---|---|
"location" | Tracks location-related questions. |
"budget" | Tracks budget questions. |
"size" | Tracks questions about size or bedrooms. |
"other" | Fallback for any additional questionnaire step. |
FilterEnum
Alias of string. You can reuse more specific filter unions from your application if required.
SortEnum
| Value | Description |
|---|---|
"cost_low_to_high" | Sorts from lowest to highest price. |
"cost_high_to_low" | Sorts from highest to lowest price. |
"newest" | Highlights the most recent units. |
"relevance" | Sorts by the backend relevance score (default). |
Example
import { QueryClientProvider } from "@tanstack/react-query";
import {
AnalyticsProvider,
store,
useTrackingEvents,
type ClickUnitEvent,
} from "@superbright/indexeddb-orm";
import { useVisitorSession } from "./hooks/useVisitorSession";
// ── Step 1: outer providers ───────────────────────────────────────────────
// QueryClientProvider must wrap InnerProviders so that useVisitorSession
// (which calls useQuery) has access to the QueryClient.
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<InnerProviders>{children}</InnerProviders>
</QueryClientProvider>
);
}
// ── Step 2: inner providers ───────────────────────────────────────────────
// Lives inside QueryClientProvider, so hooks are safe to call here.
function InnerProviders({ children }: { children: React.ReactNode }) {
// Reads visitor_uuid from IndexedDB; fetches from API and persists it if absent.
const visitor_uuid = useVisitorSession(getSlugFromPathname());
return (
<AnalyticsProvider
visitor_uuid={visitor_uuid}
mixpanelToken={import.meta.env.VITE_MIXPANEL_TOKEN}
posthogKey={import.meta.env.VITE_POSTHOG_KEY}
posthogHost={import.meta.env.VITE_POSTHOG_HOST}
envMode={import.meta.env.MODE ?? "production"}
>
{children}
</AnalyticsProvider>
);
}
// ── Step 3: read visitor UUID imperatively (non-React contexts) ───────────
// Use the store methods directly when hooks are not available.
const uuid = await store.getVisitorUUID(); // → string | null
await store.setVisitorUUID("550e8400-e29b-41d4-a716-446655440000");
// ── Step 4: track events anywhere inside AnalyticsProvider ────────────────
function UnitCard({ unit }: { unit: ClickUnitEvent }) {
const { trackClickUnit } = useTrackingEvents();
const handleClick = () => {
trackClickUnit({
propertyId: unit.propertyId,
propertySlug: unit.propertySlug,
propertyName: unit.propertyName,
unitId: unit.unitId,
unitSlug: unit.unitSlug,
unitTitle: unit.unitTitle,
favoritedUnit: unit.favoritedUnit,
});
};
return <button onClick={handleClick}>{unit.unitTitle}</button>;
}
By colocating analytics with the ORM, any future features (for example, stateful funnels or multi-property telemetry) can reuse the same interfaces and user identity helpers without additional setup.