Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: analytics #807

Merged
merged 9 commits into from
Sep 29, 2023
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/nextjs_bundle_analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ jobs:
id: fc
with:
issue-number: ${{ github.event.number }}
body-includes: '<!-- __NEXTJS_BUNDLE -->'
body-includes: '<!-- __NEXTJS_BUNDLE_@weareinreach/app -->'

- name: Create Comment
uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3
Expand Down
4 changes: 4 additions & 0 deletions InReach.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
"name": "📦 tRPC API (@weareinreach/api)",
"path": "./packages/api"
},
{
"name": "🤷🏻 Analytics (@weareinreach/analytics)",
"path": "./packages/analytics"
},
{
"name": "🔐 Authentication (@weareinreach/auth)",
"path": "./packages/auth"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,4 @@ See [LICENSE](LICENSE) for more information.

[![Powered by Vercel](.github/images/powered-by-vercel.svg)](https://vercel.com/?utm_source=in-reach&utm_campaign=oss)

[![Structured Content Powered by Sanity](.github/images/sanity-logo.svg)](https://sanity.io)
[![built with Codeium](https://codeium.com/badges/main)](https://codeium.com)
1 change: 1 addition & 0 deletions apps/app/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const nextConfig = {
reactStrictMode: true,
swcMinify: true,
transpilePackages: [
'@weareinreach/analytics',
'@weareinreach/api',
'@weareinreach/auth',
'@weareinreach/crowdin',
Expand Down
4 changes: 4 additions & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"@vercel/analytics": "1.0.2",
"@vercel/edge-config": "0.4.1",
"@vercel/kv": "0.2.3",
"@weareinreach/analytics": "workspace:*",
"@weareinreach/api": "workspace:*",
"@weareinreach/auth": "workspace:*",
"@weareinreach/crowdin": "workspace:*",
Expand Down Expand Up @@ -93,12 +94,14 @@
"next-auth": "4.23.1",
"next-i18next": "14.0.3",
"next-seo": "6.1.0",
"nextjs-google-analytics": "2.3.3",
"nextjs-routes": "2.0.1",
"object-sizeof": "2.6.3",
"pretty-bytes": "6.1.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-error-boundary": "4.0.11",
"react-hook-consent": "3.3.0",
"react-hook-form": "7.46.2",
"react-hook-form-mantine": "2.0.0",
"react-i18next": "13.2.2",
Expand All @@ -112,6 +115,7 @@
"@tanstack/react-table-devtools": "8.7.6",
"@total-typescript/ts-reset": "0.5.1",
"@types/eslint": "8.44.3",
"@types/gtag.js": "0.0.14",
"@types/luxon": "3.3.2",
"@types/node": "18.18.0",
"@types/react": "18.2.23",
Expand Down
15 changes: 14 additions & 1 deletion apps/app/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@
"connect": "Connect",
"contact": "Contact",
"contact-us": "Contact Us",
"cookie-consent": {
"approve-all": "Approve all",
"approve-selected": "Approve selected",
"body": "We use cookies and third party services on this website. Some of them are essential, others help us to improve your browsing experience. Please see our <PrivacyLink>Privacy Statement</PrivacyLink> for more information.",
"intro": "We use necessary cookies to make our site work. We'd like to set additional cookies to understand site usage, make site improvements and to remember your settings.",
"item-basic": "Necessary cookies for site functionality",
"item-ga4": "Analytics for internal InReach use",
"modal-title": "Cookies Settings"
},
"count": {
"result_one": "{{count}} result",
"result_other": "{{count}} results"
Expand Down Expand Up @@ -206,7 +215,7 @@
"<textUtility4>(*The only exception is the Local Community Reviewer user account type, which leverages any demographic information collected to enhance Local Community Reviewers’ volunteering experience and impact. For Local Community Reviewer accounts, we require location and all other demographic information is optional but would be tied to their user record. This data would only be visible outside of InReach staff if the Local Community Reviewer chooses to make it visible through their account privacy settings. Sharing a public-facing profile, will help other users’ to feel more confident in your reviews.)</textUtility4>",
"<textDarkGray>Understanding our user demographics can help us to quantify our impact, raise funds, and evaluate future product decisions. We appreciate users voluntarily sharing this information with us, and we take these steps to protect your privacy and anonymity.</textDarkGray>",
"<textDarkGray><title2>Site usage Analytics</title2></textDarkGray>",
"<textDarkGray>InReach uses <linkUmami>Umami</linkUmami>, a privacy-focused alternative to Google Analytics, to anonymously analyze and track App usage data (e.g., user searches and other behavior) in the aggregate. <linkUmamiGDPR>Umami is GDPR compliant</linkUmamiGDPR> and does not collect any personally identifiable information and anonymizes all data collected. Users cannot be identified and are never tracked across websites.</textDarkGray>",
"<textDarkGray>InReach uses Google Analytics to anonymously analyze and track App usage data (e.g., user searches and other behavior) in the aggregate. Google Analytics is GDPR compliant and does not collect any personally identifiable information and anonymizes all data collected. Users cannot be identified and are never tracked across websites.</textDarkGray>",
"<textDarkGray>InReach also uses Vercel Analytics to analyze web vitals and performance. Vercel Analytics provides site usage information without being tied to, or associated with, any individual visitor or IP address. The recording of data points is anonymous and the Analytics feature does not collect or store information that would enable Vercel to reconstruct a browsing session across pages or identify a user.</textDarkGray>"
],
"privacy-statement-foot": "<linkPolicy>Read our full privacy policy for more information</linkPolicy>",
Expand Down Expand Up @@ -375,16 +384,20 @@
"add": "Add",
"and": "and",
"and-x-more": "and {{count}} more",
"approve": "Approve",
"back": "Back",
"close": "Close",
"coming-soon": "Coming soon",
"customize": "Customize",
"decline": "Decline",
"delete": "Delete",
"distance": "Distance",
"email": "Email",
"home": "Home",
"hours": "Hours",
"location": "Location",
"more": "more",
"more-info": "More information",
"next": "Next",
"no": "No",
"organization": "Organization",
Expand Down
55 changes: 21 additions & 34 deletions apps/app/src/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,25 @@
import { MantineProvider, Space } from '@mantine/core'
import { ModalsProvider } from '@mantine/modals'
import { Space } from '@mantine/core'
import { Notifications } from '@mantine/notifications'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { Analytics } from '@vercel/analytics/react'
import { type NextPage } from 'next'
import { type AppProps } from 'next/app'
import { Work_Sans } from 'next/font/google'
import { type AppProps, type NextWebVitalsMetric } from 'next/app'
import { useRouter } from 'next/router'
import { type Session } from 'next-auth'
import { SessionProvider } from 'next-auth/react'
import { appWithTranslation } from 'next-i18next'
import { DefaultSeo, type DefaultSeoProps } from 'next-seo'
import { GoogleAnalytics } from 'nextjs-google-analytics'

import { appEvent } from '@weareinreach/analytics/events'
import { PageLoadProgress } from '@weareinreach/ui/components/core/PageLoadProgress'
import { Footer } from '@weareinreach/ui/components/sections/Footer'
import { Navbar } from '@weareinreach/ui/components/sections/Navbar'
import { useScreenSize } from '@weareinreach/ui/hooks/useScreenSize'
import { BodyGrid } from '@weareinreach/ui/layouts/BodyGrid'
import { GoogleMapsProvider } from '@weareinreach/ui/providers/GoogleMaps'
import { SearchStateProvider } from '@weareinreach/ui/providers/SearchState'
import { appCache, appTheme } from '@weareinreach/ui/theme'
import { Providers } from '~app/providers'
import { api } from '~app/utils/api'

import nextI18nConfig from '../../next-i18next.config.mjs'
import 'core-js/features/array/at'

const fontWorkSans = Work_Sans({ subsets: ['latin'] })

const defaultSEO = {
titleTemplate: '%s | InReach',
Expand Down Expand Up @@ -59,6 +53,10 @@ const defaultSEO = {
],
} satisfies DefaultSeoProps

export function reportWebVitals(stats: NextWebVitalsMetric) {
appEvent.webVitals(stats)
}

const MyApp = (appProps: AppPropsWithGridSwitch) => {
const {
Component,
Expand All @@ -80,30 +78,19 @@ const MyApp = (appProps: AppPropsWithGridSwitch) => {
)

return (
<SessionProvider session={session}>
<Providers session={session}>
<DefaultSeo {...defaultSEO} />
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{ ...appTheme, fontFamily: fontWorkSans.style.fontFamily }}
emotionCache={appCache}
>
<ModalsProvider>
<SearchStateProvider>
<GoogleMapsProvider>
<PageLoadProgress />
<Navbar />
{PageContent}
{(isMobile || isTablet) && <Space h={80} />}
<Footer />
<Notifications transitionDuration={500} />
</GoogleMapsProvider>
</SearchStateProvider>
</ModalsProvider>
<ReactQueryDevtools initialIsOpen={false} toggleButtonProps={{ style: { zIndex: 99998 } }} />
<Analytics />
</MantineProvider>
</SessionProvider>
<GoogleAnalytics trackPageViews defaultConsent='denied' />

<PageLoadProgress />
<Navbar />
{PageContent}
{(isMobile || isTablet) && <Space h={80} />}
<Footer />
<Notifications transitionDuration={500} />
<ReactQueryDevtools initialIsOpen={false} toggleButtonProps={{ style: { zIndex: 99998 } }} />
<Analytics />
</Providers>
)
}

Expand Down
127 changes: 127 additions & 0 deletions apps/app/src/providers/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
'use client'
import { MantineProvider } from '@mantine/core'
import { ModalsProvider } from '@mantine/modals'
import dynamic, { type LoaderComponent } from 'next/dynamic'
import { Work_Sans } from 'next/font/google'
import { type Session } from 'next-auth'
import { SessionProvider } from 'next-auth/react'
import { Trans, useTranslation } from 'next-i18next'
import { type ComponentPropsWithoutRef, useMemo } from 'react'
import { ConsentBanner, type ConsentOptions, ConsentProvider } from 'react-hook-consent'

import { GoogleMapsProvider } from '@weareinreach/ui/providers/GoogleMaps'
import { SearchStateProvider } from '@weareinreach/ui/providers/SearchState'
import { appCache, appTheme } from '@weareinreach/ui/theme'
import 'react-hook-consent/dist/styles/style.css'

const fontWorkSans = Work_Sans({
subsets: ['latin-ext'],
weight: ['400', '500', '600'],
fallback: [
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'Roboto',
'Helvetica',
'Arial',
'sans-serif',
'Apple Color Emoji',
'Segoe UI Emoji',
],
})

const PrivacyStatementModal = dynamic(
() =>
import('@weareinreach/ui/modals/PrivacyStatement').then(
(mod) => mod.PrivacyStatementModal
) satisfies LoaderComponent
)
const Link = dynamic(() => import('@weareinreach/ui/components/core/Link').then((mod) => mod.Link))

export const Providers = ({ children, session }: ProviderProps) => {
const { t } = useTranslation('common')

const consentOptions: ConsentOptions = useMemo(
() => ({
services: [
{
id: 'basic',
name: t('cookie-consent.item-basic'),
mandatory: true,
},
{
id: 'ga4',
name: t('cookie-consent.item-ga4'),
scripts: [
{
id: 'ga4-consent',
code: `window.gtag && window.gtag('consent', 'update', {ad_storage: 'granted', analytics_storage: 'granted'})`,
},
],
},
],
theme: 'light',
}),
[t]
)

const consentBannerSettings: ConsentBannerOpts = useMemo(
() => ({
settings: {
modal: {
title: t('cookie-consent.modal-title'),
approve: { label: t('cookie-consent.approve-selected') },
approveAll: { label: t('cookie-consent.approve-all') },
decline: { label: t('words.decline') },
description: (
<Trans
i18nKey='cookie-consent.body'
components={{
PrivacyLink: (
/* @ts-expect-error -> This is a dynamic component */
<PrivacyStatementModal component={Link} variant='inlineInvertedUtil1' />
),
}}
/>
),
},
label: t('words.customize'),
},
approve: { label: t('words.accept') },
decline: { label: t('words.decline') },
}),
[t]
)

return (
<ConsentProvider options={consentOptions}>
<SessionProvider session={session}>
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
...appTheme,
fontFamily: fontWorkSans.style.fontFamily,
}}
emotionCache={appCache}
>
<ModalsProvider>
<SearchStateProvider>
<GoogleMapsProvider>
{children}
<ConsentBanner {...consentBannerSettings}>{t('cookie-consent.intro')}</ConsentBanner>
</GoogleMapsProvider>
</SearchStateProvider>
</ModalsProvider>
</MantineProvider>
</SessionProvider>
</ConsentProvider>
)
}

type ProviderProps = {
children: React.ReactNode
session: Session
}

type ConsentBannerOpts = ComponentPropsWithoutRef<typeof ConsentBanner>
5 changes: 5 additions & 0 deletions packages/analytics/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/* eslint-disable import/no-unused-modules */
module.exports = {
extends: ['@weareinreach/eslint-config/next'],
root: true,
}
1 change: 1 addition & 0 deletions packages/analytics/.lintstagedrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@weareinreach/config/lint-staged')
44 changes: 44 additions & 0 deletions packages/analytics/events/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import compact from 'just-compact'
import { type NextWebVitalsMetric } from 'next/app'

import { event } from '../lib/event'

// eslint-disable-next-line import/consistent-type-specifier-style
import type { ServiceCategoryToggleAction, ServiceModalOpenedAction } from './types'

export const serviceFilterEvent = {
select: (serviceId: string, service?: string, category?: string) =>
event('service_filter_select', { serviceId, service_name: service, service_category: category }),
unselect: (serviceId: string, service?: string, category?: string) =>
event('service_filter_unselect', { serviceId, service_name: service, service_category: category }),
toggleCategory: (category: string, action: ServiceCategoryToggleAction) =>
event('service_filter_category_toggle', { service_category: category, action }),
deselectAll: (selectedServices: (string | undefined)[]) =>
event('service_filter_deselect_all', { service_name: compact(selectedServices) }),
}

export const navbarEvent = {
safetyExit: () => event('safety_exit'),
}

export const searchBoxEvent = {
searchLocation: (term: string, placeId: string) =>
event('search', { search_term: term, google_place_id: placeId }),
searchOrg: (term: string, selectedOrg: string) =>
event('orgSearch', { search_term: term, selected_org: selectedOrg }),
}

export const appEvent = {
webVitals: ({ id, name, label, value }: NextWebVitalsMetric) =>
event(name, {
category: label === 'web-vital' ? 'Web Vitals' : 'Next.js custom metric',
value: Math.round(name === 'CLS' ? value * 1000 : value), // values must be integers
label: id, // id unique to current page load
nonInteraction: true, // avoids affecting bounce rate.
}),
}

export const serviceModalEvent = {
opened: ({ serviceId, serviceName, orgSlug }: ServiceModalOpenedAction) =>
event('select_content', { content_type: 'orgService', content_id: serviceId, serviceName, orgSlug }),
}
3 changes: 3 additions & 0 deletions packages/analytics/events/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type ServiceCategoryToggleAction = 'select' | 'unselect' | 'select_from_partial'

export type ServiceModalOpenedAction = { serviceId: string; serviceName?: string; orgSlug: string }
Loading
Loading