Skip to content

Commit

Permalink
Merge pull request #25 from jakala-na/feat/analytic-implementation
Browse files Browse the repository at this point in the history
Feat(analytic): Analytics implementation outline.
  • Loading branch information
asgorobets authored Oct 22, 2024
2 parents 24dfe2c + 79bc34f commit 84977d6
Show file tree
Hide file tree
Showing 11 changed files with 335 additions and 72 deletions.
25 changes: 14 additions & 11 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { draftMode } from 'next/headers';

import { VercelToolbar } from '@vercel/toolbar/next';
import { AnalyticsComponent } from 'components/analytics';
import { graphql } from 'gql.tada';

import { graphqlClient } from '#/lib/graphqlClient';
import { ContentfulPreviewProvider } from '#/components/contentful-preview-provider';

import './globals.css';
import { graphqlClient } from '../lib/graphqlClient';

import { graphql } from 'gql.tada';
import './globals.css';

import { ContentfulPreviewProvider } from '#/components/contentful-preview-provider';
import { NavigationFieldsFragment } from '#/components/navigation';
import { SiteHeader } from '#/components/site-header';
import { isContentSourceMapsEnabled } from '#/lib/contentSourceMaps';
Expand Down Expand Up @@ -47,13 +48,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
*/}
<head />
<body className={cn('min-h-screen bg-background font-sans antialiased', fontSans.variable)}>
<ContentfulPreviewProvider isDraftMode={isDraftMode} isContentSourceMapsEnabled={isContentSourceMapsEnabled}>
<div className="relative flex min-h-screen flex-col">
<SiteHeader navigationData={layoutData.data?.navigationMenuCollection} />
<div className="flex-1">{children}</div>
{shouldInjectToolbar && <VercelToolbar />}
</div>
</ContentfulPreviewProvider>
<AnalyticsComponent>
<ContentfulPreviewProvider isDraftMode={isDraftMode} isContentSourceMapsEnabled={isContentSourceMapsEnabled}>
<div className="relative flex min-h-screen flex-col">
<SiteHeader navigationData={layoutData.data?.navigationMenuCollection} />
<div className="flex-1">{children}</div>
{shouldInjectToolbar && <VercelToolbar />}
</div>
</ContentfulPreviewProvider>
</AnalyticsComponent>
</body>
</html>
);
Expand Down
44 changes: 44 additions & 0 deletions components/analytics/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Analytics

Analytics solution is based on the [getanalytics.io](https://getanalytics.io/) library.
The library provides three base API interfaces to sent tracking information:
- `page()` - trigger page view. This will trigger page calls in any installed plugins
- `identify()` - this will trigger identify calls in any installed plugins and will set user data in localStorage
- `track()` - Track an analytics event. This will trigger track calls in any installed plugins

## NextJS Integration

We will provide integration with NextJS for three different cases: page view,
component in view, click on target. See the `components/analytics/analytics.tsx`
file where we convey the global Analytics context and define a hook to track
page view. Then go to the `app/layout.tsx` where we wrap all children components
inside the analytics context.

### Page view tracking

Page view will automatically triggered after the page will be loaded depending
on the NextJS router `pathname` changes.

### Component InView tracking

We introduced the wrapper component `TrackInView` that can be used to wrap any
client component and send analytics tracking event when the component is fully viewed.

### On Click analytics

Here's the place where we should communicate with the UI component and current
implementation suppose just passing the click tracking event callback to the UI
component and then the passed callback can be attached to any of the elements
inside UI components on demand.

## Type Safe Events

We provide interfaces for each event in the `components/analytics/tracking-events.ts`.
Each event should be registered in the `EventsMap` interface and for each event
data should be provided own interface that describes the event data modal and
will be the value of type the registered event in the `EventsMap`
Also that file provides a helper function `createAnalyticsEvent()` that should be
used to create any analytics event on the client level. This function will accept
the event name string as the first argument and the event data object as the
second argument and map given event name to the corresponding event data type.
It will guarantee that all events will have correct data.
22 changes: 22 additions & 0 deletions components/analytics/analytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client';

import { PropsWithChildren, useEffect } from 'react';
import { usePathname } from 'next/navigation';

import Analytics from 'analytics';
import { AnalyticsProvider } from 'use-analytics';

const analyticsInstance = Analytics({
app: 'starterkit',
debug: true,
});

export function AnalyticsComponent({ children }: PropsWithChildren) {
const pathname = usePathname();

useEffect(() => {
analyticsInstance.page();
}, [pathname]);

return <AnalyticsProvider instance={analyticsInstance}>{children}</AnalyticsProvider>;
}
1 change: 1 addition & 0 deletions components/analytics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './analytics';
26 changes: 26 additions & 0 deletions components/analytics/trackInView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import { InView } from 'react-intersection-observer';
import { useAnalytics } from 'use-analytics';

import { EventData, EventName } from './tracking-events';

interface TrackInViewProps {
eventName: EventName;
eventData: EventData<EventName>;
children: React.ReactNode;
}

export const TrackInView = ({ eventName, eventData, children }: TrackInViewProps) => {
const { track } = useAnalytics();
const onComponentIntersection = (inView: boolean) => {
if (inView) {
track(eventName, eventData);
}
};
return (
<InView triggerOnce threshold={1} onChange={(inView) => onComponentIntersection(inView)}>
{children}
</InView>
);
};
26 changes: 26 additions & 0 deletions components/analytics/tracking-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
interface EventsMap {
heroBannerViewed: heroBannerViewedProps;
duplexViewed: duplexViewedProps;
duplexClicked: duplexClickedProps;
}

interface heroBannerViewedProps {
category: string;
}

interface duplexViewedProps {
category: string;
type: string;
}

interface duplexClickedProps extends duplexViewedProps {}

export type EventName = keyof EventsMap;
export type EventData<T extends keyof EventsMap> = EventsMap[T];

export function createAnalyticsEvent<T extends EventName>(eventName: T, eventData: EventData<T>) {
return {
eventName,
eventData,
};
}
84 changes: 54 additions & 30 deletions components/duplex-ctf/duplex-ctf-client.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
'use client';

import { ResultOf } from 'gql.tada';
import { useAnalytics } from 'use-analytics';

import { createAnalyticsEvent } from '#/components/analytics/tracking-events';
import { TrackInView } from '#/components/analytics/trackInView';
import { RichTextCtf } from '#/components/rich-text-ctf';

import { useComponentPreview } from '../hooks/use-component-preview';
Expand All @@ -10,41 +13,62 @@ import { getPageLinkChildProps } from '../page';
import { Duplex } from '../ui/duplex';
import { ComponentDuplexFieldsFragment } from './duplex-ctf';

// We can create analytics event typed data on top level
// using createAnalyticsEvent helper.
// It will us type-safely create event name and event data.
const analyticsInViewEvent = createAnalyticsEvent('duplexViewed', {
category: 'duplexViewed',
type: 'ctf',
});

// For the direct track() usage from hook we can destructure the object
// returned from createAnalyticsEvent helper to have separate values as props.
const { eventName: analyticsClickEventName, eventData: analyticsClickEventData } = createAnalyticsEvent(
'duplexClicked',
{
category: 'duplexClicked',
type: 'ctf',
}
);

export const DuplexCtfClient: React.FC<{
data: ResultOf<typeof ComponentDuplexFieldsFragment>;
}> = (props) => {
const { data: originalData } = props;
const { data, addAttributes } = useComponentPreview<typeof originalData>(originalData);

const { track } = useAnalytics();
return (
<Duplex
headline={data.headline}
bodyText={
data.bodyText && (
<div {...addAttributes('bodyText')}>
<RichTextCtf {...data.bodyText} />
</div>
)
}
image={
data.image &&
getImageChildProps({
data: data.image,
priority: true,
sizes: '100vw',
})
}
imageAlignment={data.containerLayout ? 'left' : 'right'}
imageHeight={data.imageStyle ? 'fixed' : 'full'}
addAttributes={addAttributes}
cta={
data.targetPage &&
getPageLinkChildProps({
data: data.targetPage,
children: data.ctaText,
})
}
colorPalette={data.colorPalette}
/>
<TrackInView {...analyticsInViewEvent}>
<Duplex
headline={data.headline}
bodyText={
data.bodyText && (
<div {...addAttributes('bodyText')}>
<RichTextCtf {...data.bodyText} />
</div>
)
}
image={
data.image &&
getImageChildProps({
data: data.image,
priority: true,
sizes: '100vw',
})
}
imageAlignment={data.containerLayout ? 'left' : 'right'}
imageHeight={data.imageStyle ? 'fixed' : 'full'}
addAttributes={addAttributes}
cta={
data.targetPage &&
getPageLinkChildProps({
data: data.targetPage,
children: data.ctaText,
})
}
colorPalette={data.colorPalette}
onClickAnalyticsEvent={() => track(analyticsClickEventName, analyticsClickEventData)}
/>
</TrackInView>
);
};
55 changes: 31 additions & 24 deletions components/hero-banner-ctf/hero-banner-ctf-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,49 @@

import { ResultOf } from 'gql.tada';

import { createAnalyticsEvent } from '#/components/analytics/tracking-events';
import { TrackInView } from '#/components/analytics/trackInView';
import { ComponentHeroBannerFieldsFragment } from '#/components/hero-banner-ctf/hero-banner-ctf';
import { RichTextCtf } from '#/components/rich-text-ctf';

import { useComponentPreview } from '../hooks/use-component-preview';
import { getImageChildProps } from '../image-ctf';
import { getPageLinkChildProps } from '../page';
import { HeroBanner } from '../ui/hero-banner';
import { ComponentHeroBannerFieldsFragment } from './hero-banner-ctf';

export const HeroBannerCtfClient: React.FC<{
data: ResultOf<typeof ComponentHeroBannerFieldsFragment>;
}> = (props) => {
const { data: originalData } = props;
const { data, addAttributes } = useComponentPreview<typeof originalData>(originalData);

// We use createAnalyticsEvent helper to create typed event.
const analyticsInViewEvent = createAnalyticsEvent('heroBannerViewed', {
category: 'duplexViewed',
});
return (
<HeroBanner
headline={data.headline}
bodyText={data.bodyText && <RichTextCtf {...data.bodyText} />}
cta={
data.targetPage &&
getPageLinkChildProps({
data: data.targetPage,
children: data.ctaText,
})
}
image={
data.image &&
getImageChildProps({
data: data.image,
sizes: '100vw',
priority: true,
})
}
size={data.heroSize}
colorPalette={data.colorPalette}
addAttributes={addAttributes}
/>
<TrackInView {...analyticsInViewEvent}>
<HeroBanner
headline={data.headline}
bodyText={data.bodyText && <RichTextCtf {...data.bodyText} />}
cta={
data.targetPage &&
getPageLinkChildProps({
data: data.targetPage,
children: data.ctaText,
})
}
image={
data.image &&
getImageChildProps({
data: data.image,
sizes: '100vw',
priority: true,
})
}
size={data.heroSize}
colorPalette={data.colorPalette}
addAttributes={addAttributes}
/>
</TrackInView>
);
};
11 changes: 10 additions & 1 deletion components/ui/duplex/duplex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ interface DuplexProps extends VariantProps<typeof layoutVariants>, VariantProps<
cta?: LinkProps | null;
colorPalette?: string | null;
addAttributes?: (name: string) => object | null;
onClickAnalyticsEvent?: () => void;
}

export function Duplex(props: DuplexProps) {
Expand All @@ -66,6 +67,7 @@ export function Duplex(props: DuplexProps) {
imageHeight,
colorPalette,
addAttributes = () => ({}), // Default to no-op.
onClickAnalyticsEvent,
} = props;
const colorConfig = getColorConfigFromPalette(colorPalette || '');

Expand Down Expand Up @@ -95,7 +97,14 @@ export function Duplex(props: DuplexProps) {
)}
{cta?.href && cta?.children && (
<div className="mt-6">
<Button variant={colorConfig.buttonColor} {...addAttributes('ctaText')} asChild>
<Button
variant={colorConfig.buttonColor}
{...addAttributes('ctaText')}
asChild
onClick={() => {
onClickAnalyticsEvent?.();
}}
>
<Link {...cta} />
</Button>
</div>
Expand Down
Loading

0 comments on commit 84977d6

Please sign in to comment.