Add plausible
This commit is contained in:
parent
48f2c97487
commit
4857800fca
14 changed files with 118 additions and 6 deletions
|
|
@ -12,6 +12,7 @@ import LicenseSuccessModal from './components/ui/LicenseSuccessModal';
|
|||
import VerificationBanner from './components/ui/VerificationBanner';
|
||||
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
||||
import { fetchWithRetry, apiUrl } from './lib/api';
|
||||
import { trackEvent } from './lib/analytics';
|
||||
import { parseUrlState } from './lib/url-state';
|
||||
import { INITIAL_VIEW_STATE } from './lib/consts';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
|
|
@ -91,6 +92,10 @@ export default function App() {
|
|||
// Restore from history state (e.g. popstate)
|
||||
if (window.history.state?.page) return window.history.state.page;
|
||||
|
||||
// Unknown path — track as 404
|
||||
if (window.location.pathname !== '/') {
|
||||
trackEvent('404', { path: window.location.pathname });
|
||||
}
|
||||
return 'home';
|
||||
});
|
||||
|
||||
|
|
@ -122,6 +127,7 @@ export default function App() {
|
|||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
trackEvent('Purchase');
|
||||
setShowLicenseSuccess(true);
|
||||
refreshAuth();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useFadeInRef } from '../../hooks/useFadeIn';
|
||||
import HexCanvas from './HexCanvas';
|
||||
import ScrollStory from './ScrollStory';
|
||||
|
|
@ -6,6 +6,7 @@ import BottomIllustration from './BottomIllustration';
|
|||
import { TickerValue } from '../ui/TickerValue';
|
||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||
import { LogoIcon } from '../ui/icons/LogoIcon';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import type { FeatureMeta } from '../../types';
|
||||
|
||||
export default function HomePage({
|
||||
|
|
@ -30,6 +31,35 @@ export default function HomePage({
|
|||
const whyRef = useFadeInRef();
|
||||
const ctaRef = useFadeInRef();
|
||||
|
||||
// Scroll depth tracking
|
||||
const scrolledSections = useRef(new Set<string>());
|
||||
useEffect(() => {
|
||||
const ids = ['how-it-works', 'demo'];
|
||||
const observers: IntersectionObserver[] = [];
|
||||
ids.forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && !scrolledSections.current.has(id)) {
|
||||
scrolledSections.current.add(id);
|
||||
trackEvent('Scroll Depth', { section: id });
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
observer.observe(el);
|
||||
observers.push(observer);
|
||||
});
|
||||
return () => observers.forEach((o) => o.disconnect());
|
||||
}, []);
|
||||
|
||||
// 30s time-on-page event
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => trackEvent('Time on Page', { seconds: '30' }), 30000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
|
||||
<div className="relative" style={{ zIndex: 1 }}>
|
||||
|
|
@ -54,13 +84,17 @@ export default function HomePage({
|
|||
</p>
|
||||
<div className="flex items-center gap-4 mb-10">
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
onClick={() => {
|
||||
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
|
||||
onOpenDashboard();
|
||||
}}
|
||||
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
Explore the map
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
|
||||
const target = document.getElementById('comparison');
|
||||
if (!target) return;
|
||||
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
|
||||
|
|
@ -106,6 +140,7 @@ export default function HomePage({
|
|||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
trackEvent('CTA Click', { location: 'hero', label: 'see_it_in_action' });
|
||||
const target = document.getElementById('demo');
|
||||
if (!target) return;
|
||||
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
|
||||
|
|
@ -245,7 +280,10 @@ export default function HomePage({
|
|||
Don't leave it to chance.
|
||||
</p>
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
onClick={() => {
|
||||
trackEvent('CTA Click', { location: 'bottom', label: 'explore_map' });
|
||||
onOpenDashboard();
|
||||
}}
|
||||
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
Explore the map
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
|
||||
import MapComponent from '../map/Map';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { zoomToResolution } from '../../lib/map-utils';
|
||||
|
|
@ -147,6 +148,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
const [hexData, setHexData] = useState<HexagonData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const stepRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const trackedSteps = useRef(new Set<number>());
|
||||
const abortRef = useRef<AbortController>();
|
||||
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
|
@ -182,7 +184,13 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) setStage(i);
|
||||
if (entry.isIntersecting) {
|
||||
setStage(i);
|
||||
if (!trackedSteps.current.has(i)) {
|
||||
trackedSteps.current.add(i);
|
||||
trackEvent('Scroll Story Step', { step: String(i) });
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: '-35% 0px -35% 0px', threshold: 0 }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
type TravelTimeInitial,
|
||||
} from '../../hooks/useTravelTime';
|
||||
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import UpgradeModal from '../ui/UpgradeModal';
|
||||
|
|
@ -261,6 +262,7 @@ export default function MapPage({
|
|||
link.download = 'perfect-postcode-export.xlsx';
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
trackEvent('Export');
|
||||
})
|
||||
.catch((err) => logNonAbortError('Export failed', err))
|
||||
.finally(() => setExporting(false));
|
||||
|
|
@ -270,6 +272,10 @@ export default function MapPage({
|
|||
onExportStateChange?.({ onExport: handleExport, exporting });
|
||||
}, [handleExport, exporting, onExportStateChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
|
||||
}, [mapData.licenseRequired]);
|
||||
|
||||
const mobileLegendMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
[viewFeature, features]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import type { POICategoryGroup } from '../../types';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { SearchInput } from '../ui/SearchInput';
|
||||
|
|
@ -31,19 +32,23 @@ export default function POIPane({
|
|||
|
||||
const toggleCategory = (category: string) => {
|
||||
const newSet = new Set(selectedCategories);
|
||||
if (newSet.has(category)) {
|
||||
const wasSelected = newSet.has(category);
|
||||
if (wasSelected) {
|
||||
newSet.delete(category);
|
||||
} else {
|
||||
newSet.add(category);
|
||||
}
|
||||
trackEvent('POI Toggle', { category, selected: String(!wasSelected) });
|
||||
onCategoriesChange(newSet);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
trackEvent('POI Select All');
|
||||
onCategoriesChange(new Set(allCategories));
|
||||
};
|
||||
|
||||
const selectNone = () => {
|
||||
trackEvent('POI Select None');
|
||||
onCategoriesChange(new Set());
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { CheckIcon } from '../ui/icons/CheckIcon';
|
|||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { apiUrl } from '../../lib/api';
|
||||
|
||||
const FEATURES = [
|
||||
|
|
@ -58,6 +59,10 @@ export default function PricingPage({
|
|||
if (scrollRef.current) setScrolledLeft(scrollRef.current.scrollLeft > 0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent('Pricing View');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(apiUrl('pricing'))
|
||||
.then((res) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { GoogleIcon } from './icons/GoogleIcon';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
|
||||
type View = 'login' | 'register' | 'forgot';
|
||||
|
||||
|
|
@ -30,6 +31,10 @@ export default function AuthModal({
|
|||
const [password, setPassword] = useState('');
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent('Auth Modal Open', { tab: initialTab });
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const switchView = useCallback(
|
||||
(newView: View) => {
|
||||
setView(newView);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import pb from '../lib/pocketbase';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
|
|
@ -52,6 +53,7 @@ export function useAuth() {
|
|||
try {
|
||||
const result = await pb.collection('users').authWithPassword(email, password);
|
||||
setUser(recordToUser(result.record));
|
||||
trackEvent('Login', { method: 'email' });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Login failed';
|
||||
setError(msg);
|
||||
|
|
@ -73,6 +75,7 @@ export function useAuth() {
|
|||
// Auto-login after registration
|
||||
const result = await pb.collection('users').authWithPassword(email, password);
|
||||
setUser(recordToUser(result.record));
|
||||
trackEvent('Register');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Registration failed';
|
||||
setError(msg);
|
||||
|
|
@ -88,6 +91,7 @@ export function useAuth() {
|
|||
try {
|
||||
const result = await pb.collection('users').authWithOAuth2({ provider });
|
||||
setUser(recordToUser(result.record));
|
||||
trackEvent('Login', { method: provider });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'OAuth login failed';
|
||||
setError(msg);
|
||||
|
|
@ -98,6 +102,7 @@ export function useAuth() {
|
|||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
trackEvent('Logout');
|
||||
pb.authStore.clear();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
|
||||
interface UseFiltersOptions {
|
||||
initialFilters: FeatureFilters;
|
||||
|
|
@ -29,6 +30,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
(name: string) => {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (!meta) return;
|
||||
trackEvent('Filter Add', { feature: name });
|
||||
if (meta.type === 'enum' && meta.values) {
|
||||
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
|
||||
} else if (meta.type === 'numeric' && meta.histogram) {
|
||||
|
|
@ -45,6 +47,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
}, []);
|
||||
|
||||
const handleRemoveFilter = useCallback((name: string) => {
|
||||
trackEvent('Filter Remove', { feature: name });
|
||||
setFilters((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
|
|
@ -84,6 +87,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
}, []);
|
||||
|
||||
const handleTogglePin = useCallback((name: string) => {
|
||||
trackEvent('Filter Pin', { feature: name });
|
||||
setPinnedFeature((prev) => (prev === name ? null : name));
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
FeatureFilters,
|
||||
|
|
@ -107,6 +108,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
|
|||
setSelectedPostcodeGeometry(null);
|
||||
} else {
|
||||
const type = isPostcode ? 'postcode' : 'hexagon';
|
||||
trackEvent('Hexagon Click', { type });
|
||||
setSelectedHexagon({ id, type, resolution });
|
||||
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
|
||||
setProperties([]);
|
||||
|
|
@ -138,6 +140,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
|
|||
|
||||
const handleViewPropertiesFromArea = useCallback(() => {
|
||||
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
|
||||
trackEvent('View Properties');
|
||||
setRightPaneTab('properties');
|
||||
setPropertiesOffset(0);
|
||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||
|
|
@ -167,6 +170,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
|
|||
|
||||
const handleLocationSearch = useCallback(
|
||||
(postcode: string, geometry: PostcodeGeometry) => {
|
||||
trackEvent('Postcode Search');
|
||||
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
|
||||
setSelectedPostcodeGeometry(geometry);
|
||||
setProperties([]);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { apiUrl, authHeaders, assertOk } from '../lib/api';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
|
||||
export function useLicense() {
|
||||
const [checkingOut, setCheckingOut] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const startCheckout = useCallback(async (referralCode?: string) => {
|
||||
trackEvent('Checkout Start', { has_referral: String(!!referralCode) });
|
||||
setCheckingOut(true);
|
||||
setError(null);
|
||||
try {
|
||||
|
|
@ -22,6 +24,7 @@ export function useLicense() {
|
|||
assertOk(res, 'Checkout');
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
trackEvent('Checkout Redirect');
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -7,5 +7,6 @@ plausibleInit({
|
|||
captureOnLocalhost: true,
|
||||
logging: true,
|
||||
fileDownloads: true,
|
||||
outboundLinks: true,
|
||||
hashBasedRouting: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import pb from '../lib/pocketbase';
|
||||
import { apiUrl, authHeaders } from '../lib/api';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
|
||||
export interface SavedSearch {
|
||||
id: string;
|
||||
|
|
@ -66,6 +67,7 @@ export function useSavedSearches(userId: string | null) {
|
|||
formData.append('screenshot', screenshotBlob, 'screenshot.png');
|
||||
|
||||
await pb.collection('saved_searches').create(formData);
|
||||
trackEvent('Search Save');
|
||||
await fetchSearches();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to save search';
|
||||
|
|
@ -82,6 +84,7 @@ export function useSavedSearches(userId: string | null) {
|
|||
setError(null);
|
||||
try {
|
||||
await pb.collection('saved_searches').delete(id);
|
||||
trackEvent('Search Delete');
|
||||
setSearches((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete search');
|
||||
|
|
|
|||
19
frontend/src/lib/analytics.ts
Normal file
19
frontend/src/lib/analytics.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { track } from '@plausible-analytics/tracker';
|
||||
|
||||
export function trackEvent(name: string, props?: Record<string, string | number | boolean>) {
|
||||
const stringProps: Record<string, string> | undefined = props
|
||||
? Object.fromEntries(Object.entries(props).map(([k, v]) => [k, String(v)]))
|
||||
: undefined;
|
||||
track(name, { props: stringProps });
|
||||
}
|
||||
|
||||
export function trackRevenue(
|
||||
name: string,
|
||||
amountPence: number,
|
||||
props?: Record<string, string | number | boolean>
|
||||
) {
|
||||
const stringProps = props
|
||||
? Object.fromEntries(Object.entries(props).map(([k, v]) => [k, String(v)]))
|
||||
: undefined;
|
||||
track(name, { props: stringProps, revenue: { amount: amountPence / 100, currency: 'GBP' } });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue