Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Quick guide: What docs does your change need?
- [ ] I have **commented** my code, particularly where ambiguous
- [ ] New and existing **unit tests pass** locally with my changes
- [ ] I have completed the **Documentation Checklist** above (or explained why N/A)
- [ ] I have considered **product analytics** for user-facing features (use `@analytics` in learn-card-app)

### πŸš€ Ready to squash-and-merge?:
- [ ] Code is backwards compatible
Expand Down
1 change: 1 addition & 0 deletions apps/learn-card-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"numeral": "^2.0.6",
"papaparse": "^5.5.1",
"pdf-lib": "^1.17.1",
"posthog-js": "^1.333.0",
"pretty-bytes": "^6.1.1",
"qrcode.react": "^4.2.0",
"query-string": "^8.2.0",
Expand Down
4 changes: 2 additions & 2 deletions apps/learn-card-app/src/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { useIsChapiInteraction } from 'learn-card-base/stores/chapiStore';
import { useSentryIdentify } from './constants/sentry';

import { Modals } from 'learn-card-base';
import { useSetFirebaseAnalyticsUserId } from './hooks/useSetFirebaseAnalyticsUserId';
import { useSetAnalyticsUserId } from '@analytics';
import { useDeviceTypeByWidth } from 'learn-card-base';
import { redirectStore } from 'learn-card-base/stores/redirectStore';
import { useAutoVerifyContactMethodWithProofOfLogin } from './hooks/useAutoVerifyContactMethodWithProofOfLogin';
Expand Down Expand Up @@ -206,7 +206,7 @@ const AppRouter: React.FC<{ initLoading: boolean }> = ({ initLoading }) => {
useLaunchDarklyIdentify({ debug: false });
useSentryIdentify({ debug: false });

useSetFirebaseAnalyticsUserId({ debug: false });
useSetAnalyticsUserId({ debug: false });
useAutoVerifyContactMethodWithProofOfLogin();
useFinalizeInboxCredentials();

Expand Down
29 changes: 16 additions & 13 deletions apps/learn-card-app/src/FullApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import NetworkListener from './components/network-listener/NetworkListener';
import { QRCodeScannerStore } from 'learn-card-base';
import Toast from 'learn-card-base/components/toast/Toast';

import { AnalyticsContextProvider } from '@analytics';
import ExternalAuthServiceProvider from './pages/sync-my-school/ExternalAuthServiceProvider';
import localforage from 'localforage';

Expand Down Expand Up @@ -155,13 +156,14 @@ const FullApp: React.FC = () => {
client={client}
persistOptions={{ persister, maxAge: CACHE_TTL }}
>
<div className="app-bar-top relative top-0 left-0 w-full z-[9999] bg-black" />
{/* <ReactQueryDevtools /> */}
<IonReactRouter history={history}>
<Suspense fallback={<LoadingPageDumb />}>
<ExternalAuthServiceProvider>
<ModalsProvider>
<IonApp>
<AnalyticsContextProvider>
<div className="app-bar-top relative top-0 left-0 w-full z-[9999] bg-black" />
{/* <ReactQueryDevtools /> */}
<IonReactRouter history={history}>
<Suspense fallback={<LoadingPageDumb />}>
<ExternalAuthServiceProvider>
<ModalsProvider>
<IonApp>
<div id="modal-mid-root"></div>
<Toast />
<NetworkListener />
Expand All @@ -182,12 +184,13 @@ const FullApp: React.FC = () => {
</button>
)}
</IonApp>
</ModalsProvider>
</ExternalAuthServiceProvider>
</Suspense>
</IonReactRouter>
</PersistQueryClientProvider>
);
</ModalsProvider>
</ExternalAuthServiceProvider>
</Suspense>
</IonReactRouter>
</AnalyticsContextProvider>
</PersistQueryClientProvider>
);
};

export default FullApp;
157 changes: 157 additions & 0 deletions apps/learn-card-app/src/analytics/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import React, { createContext, useContext, useEffect, useState, useMemo, useCallback } from 'react';

import type { AnalyticsProvider, AnalyticsProviderName } from './types';
import type { AnalyticsEventName, EventPayload } from './events';
import { NoopProvider } from './providers/noop';

/**
* Lazily load and instantiate the appropriate analytics provider based on env config.
*/
async function loadProvider(): Promise<AnalyticsProvider> {
const providerName = (
import.meta.env.VITE_ANALYTICS_PROVIDER || 'noop'
) as AnalyticsProviderName;

switch (providerName) {
case 'posthog': {
const apiKey = import.meta.env.VITE_POSTHOG_KEY;

if (!apiKey) {
console.warn('[Analytics] PostHog selected but VITE_POSTHOG_KEY not set, falling back to noop');
return new NoopProvider();
}

const { PostHogProvider } = await import('./providers/posthog');

return new PostHogProvider({
apiKey,
apiHost: import.meta.env.VITE_POSTHOG_HOST,
});
}

case 'firebase': {
const { FirebaseProvider } = await import('./providers/firebase');
return new FirebaseProvider();
}

case 'noop':
default: {
return new NoopProvider();
}
}
}

interface AnalyticsContextValue {
provider: AnalyticsProvider;
isReady: boolean;
}

const AnalyticsContext = createContext<AnalyticsContextValue | null>(null);

interface AnalyticsProviderProps {
children: React.ReactNode;
}

/**
* Analytics provider component that lazily loads the configured analytics backend.
* Wrap your app with this provider to enable analytics throughout.
*/
export function AnalyticsContextProvider({ children }: AnalyticsProviderProps) {
const [provider, setProvider] = useState<AnalyticsProvider>(() => new NoopProvider());
const [isReady, setIsReady] = useState(false);

useEffect(() => {
let mounted = true;

loadProvider()
.then(async loadedProvider => {
if (!mounted) return;

await loadedProvider.init();

if (!mounted) return;

setProvider(loadedProvider);
setIsReady(true);
})
.catch(error => {
console.error('[Analytics] Failed to load provider', error);

if (mounted) {
setIsReady(true);
}
});

return () => {
mounted = false;
};
}, []);

const value = useMemo(() => ({ provider, isReady }), [provider, isReady]);

return <AnalyticsContext.Provider value={value}>{children}</AnalyticsContext.Provider>;
}

/**
* Hook to access the analytics context.
* @internal Use useAnalytics() instead for the public API.
*/
export function useAnalyticsContext(): AnalyticsContextValue {
const context = useContext(AnalyticsContext);

if (!context) {
throw new Error('useAnalyticsContext must be used within an AnalyticsContextProvider');
}

return context;
}

/**
* Main hook for tracking analytics events.
* Provides type-safe methods for tracking, identifying users, and page views.
*/
export function useAnalytics() {
const { provider, isReady } = useAnalyticsContext();

const track = useCallback(
async <E extends AnalyticsEventName>(event: E, properties: EventPayload<E>) => {
await provider.track(event, properties);
},
[provider]
);

const identify = useCallback(
async (userId: string, traits?: Record<string, unknown>) => {
await provider.identify(userId, traits);
},
[provider]
);

const page = useCallback(
async (name: string, properties?: Record<string, unknown>) => {
await provider.page(name, properties);
},
[provider]
);

const reset = useCallback(async () => {
await provider.reset();
}, [provider]);

const setEnabled = useCallback(
async (enabled: boolean) => {
await provider.setEnabled(enabled);
},
[provider]
);

return {
track,
identify,
page,
reset,
setEnabled,
isReady,
providerName: provider.name,
};
}
104 changes: 104 additions & 0 deletions apps/learn-card-app/src/analytics/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Centralized event catalog - single source of truth for all analytics events.
* Add new events here with their corresponding payload types.
*/

export const AnalyticsEvents = {
// Boost/Credential Claims
CLAIM_BOOST: 'claim_boost',

// Boost CMS
BOOST_CMS_PUBLISH: 'boostCMS_publish',
BOOST_CMS_ISSUE_TO: 'boostCMS_issue_to',
BOOST_CMS_CONFIRMATION: 'boostCMS_confirmation',
BOOST_CMS_DATA_ENTRY: 'boostCMS_data_entry',

// Sharing & Link Generation
GENERATE_SHARE_LINK: 'generate_share_link',
GENERATE_CLAIM_LINK: 'generate_claim_link',

// Boost Sending
SELF_BOOST: 'self_boost',
SEND_BOOST: 'send_boost',

// Navigation/Screens
SCREEN_VIEW: 'screen_view',
} as const;

export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];

/**
* Type-safe payload definitions for each event.
* Extend this interface as you add new events.
*/
export interface AnalyticsEventPayloads {
[AnalyticsEvents.CLAIM_BOOST]: {
category?: string;
boostType?: string;
achievementType?: string;
method: 'VC-API Request' | 'Dashboard' | 'Claim Modal' | 'Notification' | string;
};

[AnalyticsEvents.BOOST_CMS_PUBLISH]: {
timestamp: number;
action: 'publish' | 'publish_draft' | 'publish_live';
boostType?: string;
category?: string;
};

[AnalyticsEvents.BOOST_CMS_ISSUE_TO]: {
timestamp: number;
action: 'issue_to';
boostType?: string;
category?: string;
};

[AnalyticsEvents.BOOST_CMS_CONFIRMATION]: {
timestamp: number;
action: 'confirmation';
boostType?: string;
category?: string;
};

[AnalyticsEvents.GENERATE_SHARE_LINK]: {
category?: string;
boostType?: string;
method: 'Earned Boost' | string;
};

[AnalyticsEvents.GENERATE_CLAIM_LINK]: {
category?: string;
boostType?: string;
method: 'Claim Link' | string;
};

[AnalyticsEvents.BOOST_CMS_DATA_ENTRY]: {
timestamp: number;
action: 'data_entry';
boostType?: string;
category?: string;
};

[AnalyticsEvents.SELF_BOOST]: {
category?: string;
boostType?: string;
method: 'Managed Boost' | string;
};

[AnalyticsEvents.SEND_BOOST]: {
category?: string;
boostType?: string;
method: 'Managed Boost' | string;
};

[AnalyticsEvents.SCREEN_VIEW]: {
screen_name: string;
};
}

/**
* Helper type to get the payload type for a specific event.
*/
export type EventPayload<E extends AnalyticsEventName> = E extends keyof AnalyticsEventPayloads
? AnalyticsEventPayloads[E]
: Record<string, unknown>;
7 changes: 7 additions & 0 deletions apps/learn-card-app/src/analytics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { AnalyticsContextProvider, useAnalytics, useAnalyticsContext } from './context';
export { useSetAnalyticsUserId } from './useSetAnalyticsUserId';

export { AnalyticsEvents } from './events';
export type { AnalyticsEventName, AnalyticsEventPayloads, EventPayload } from './events';

export type { AnalyticsProvider, AnalyticsProviderName, PostHogConfig } from './types';
Loading
Loading