Skip to content

Commit 7028a60

Browse files
authored
add matomo analytics to app (#50)
1 parent cfe2172 commit 7028a60

File tree

20 files changed

+352
-115
lines changed

20 files changed

+352
-115
lines changed

global.d.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
declare global {
2+
interface Window {
3+
_paq?: any[];
4+
}
5+
}
6+
7+
export {};

src/api/mailchimp/subscribe-newsletter.ts

+2
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ export default async function subscribeNewsletterHandler({
1717
body: JSON.stringify({ email, name, tags }),
1818
});
1919
const result = await response.json();
20+
2021
if (response.ok) {
2122
return { subscribed: true, message: result.message };
2223
}
24+
2325
throw Error(result.message, { cause: result });
2426
}

src/app/api/newsletter/route.ts

+15-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { createHash } from 'crypto';
12
import { captureException } from '@sentry/nextjs';
23
import { z } from 'zod';
4+
35
import { env } from '@/env.mjs';
46

57
const API_KEY = env.MAILCHIMP_API_KEY;
@@ -9,12 +11,14 @@ const url = `https://${API_SERVER}.api.mailchimp.com/3.0/lists/${AUDIENCE_ID}/me
911

1012
const newsletterFormSchema = z.object({
1113
email: z.string().email({ message: 'Please enter a valid email address.' }),
12-
name: z.string({ message: 'Please enter a name.' }).min(2, { message: 'Please a correct name' }),
14+
name: z
15+
.string({ message: 'Please enter a name.' })
16+
.min(2, { message: 'Please a correct name' })
17+
.optional(),
1318
tags: z.array(z.string()).optional(),
1419
});
1520

1621
const ErrorMessageMap = {
17-
'Member Exists': "Uh oh, it looks like this email's already subscribed 🧐.",
1822
'Invalid Resource': 'Please provide a valid email address.',
1923
default: 'Adding new email to newsletter audience failed',
2024
};
@@ -30,7 +34,7 @@ type MailchimpErrorResponse = {
3034

3135
type RequestBody = {
3236
email: string;
33-
name: string;
37+
name?: string;
3438
tags?: Array<string>;
3539
};
3640

@@ -92,9 +96,14 @@ export async function POST(req: Request) {
9296
},
9397
};
9498

99+
const subscriberHash = createHash('md5')
100+
.update(formValidation.email.trim().toLowerCase())
101+
.digest('hex');
102+
const mailchimpUri = `${url}/${subscriberHash}`;
103+
95104
try {
96-
const response = await fetch(url, {
97-
method: 'post',
105+
const response = await fetch(mailchimpUri, {
106+
method: 'put',
98107
headers: {
99108
'Content-Type': 'application/json',
100109
Authorization: `api_key ${API_KEY}`,
@@ -116,7 +125,7 @@ export async function POST(req: Request) {
116125
return Response.json(
117126
{
118127
message: getErrorMessage(result.title ?? 'default'),
119-
reason: result.title ?? 'unknown',
128+
reason: result.title ?? null,
120129
},
121130
{ status: result.status ?? 400 }
122131
);

src/app/layout.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { Gabarito, Titillium_Web, DM_Serif_Text } from 'next/font/google';
21
import { ReactNode, Suspense } from 'react';
3-
import Providers from './providers';
2+
import { Gabarito, Titillium_Web, DM_Serif_Text } from 'next/font/google';
43

4+
import Providers from './providers';
5+
import MatomoAnalyticsConsent from '@/components/Matomo';
56
import { auth } from '@/auth';
7+
68
import '@/styles/globals.scss';
79

810
const titilliumWeb = Titillium_Web({
@@ -37,6 +39,7 @@ export default async function RootLayout({ children }: RootLayoutProps) {
3739
<body>
3840
<Providers session={session}>
3941
<Suspense fallback={null}>{children}</Suspense>
42+
<MatomoAnalyticsConsent />
4043
{/* <Feedback /> */}
4144
</Providers>
4245
</body>

src/app/privacy/page.tsx

+12-4
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,34 @@
11
'use client';
22

3+
import Link from 'next/link';
4+
35
import SectionGeneric from '@/components/LandingPage/sections/SectionGeneric';
46
import PaddingBlock from '@/components/LandingPage/components/PaddedBlock/PaddedBlock';
57
import Hero from '@/components/LandingPage/layout/Hero/Hero';
6-
import LogoAsLink from '@/components/logo/as-link';
7-
88
import { EnumSection } from '@/components/LandingPage/sections/sections';
99
import { classNames } from '@/util/utils';
10+
1011
import styles from '@/components/LandingPage/LandingPage.module.css';
1112

1213
export default function page() {
1314
return (
1415
<div className={classNames(styles.landingPage)}>
1516
<div className="relative z-10">
16-
<div className="absolute top-0 px-6 py-8 text-white md:px-12">
17-
<LogoAsLink type="svg" />
17+
<div className="absolute top-0 p-[19px] text-white md:p-[19px]">
18+
<Link href="/" className="max-w-[8em] flex-none font-serif text-[19px]">
19+
<h2 className="relative text-balance text-right leading-[0.8]">
20+
Open Brain <br /> Institute
21+
</h2>
22+
</Link>
1823
</div>
1924
</div>
2025
<Hero section={EnumSection.PrivacyPolicy} />
2126
<PaddingBlock>
2227
<SectionGeneric section={EnumSection.PrivacyPolicy} />
2328
</PaddingBlock>
29+
<div className="bg-white px-2 py-2 text-base text-gray-400 md:px-[19px]">
30+
Copyright &copy; {new Date().getFullYear()} – Open Brain Institute
31+
</div>
2432
</div>
2533
);
2634
}

src/app/virtual-lab/(home)/page.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { ReactNode } from 'react';
22
import { redirect } from 'next/navigation';
3+
import { Metadata } from 'next';
34
import { getVirtualLabsOfUser } from '@/services/virtual-lab/labs';
45
import VirtualLabDashboard from '@/components/VirtualLab/VirtualLabDashboard';
56

7+
export const metadata: Metadata = {
8+
title: 'Virtual labs',
9+
};
10+
611
export default async function VirtualLabMainPage() {
712
let redirectPath: string | null = null;
813
let toRender: ReactNode = null;

src/app/virtual-lab/lab/[virtualLabId]/(lab)/overview/page.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import DiscoverObpPanel from '@/components/VirtualLab/DiscoverObpPanel';
22

33
import VirtualLabHome from '@/components/VirtualLab/VirtualLabHomePage';
4-
import { ServerSideComponentProp } from '@/types/common';
54
import NewProjectCTABanner from '@/components/VirtualLab/VirtualLabCTABanner/NewProjectCTABanner';
5+
import { ServerSideComponentProp } from '@/types/common';
66
import { UsersHorizontalList } from '@/components/VirtualLab/projects/VirtualLabProjectHomePage';
77

88
export default function VirtualLab({ params }: ServerSideComponentProp<{ virtualLabId: string }>) {

src/components/LandingPage/Matomo.tsx

-17
This file was deleted.

src/components/LandingPage/constants.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const SECTIONS: Readonly<Section[]> = [
2929
caption: 'Terms',
3030
slug: `${SLUG_PREFIX}terms`,
3131
},
32-
{ index: EnumSection.ComingSoon, caption: 'Coming Soon', slug: `/` },
32+
{ index: EnumSection.ComingSoon, caption: 'Coming Soon', slug: `/releasing-soon` },
3333
];
3434

3535
export const MENU_ITEMS: Readonly<Array<{ caption: string; index: EnumSection }>> = [

src/components/LandingPage/content/hero.ts

+1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export function useSanityContentForHero(sectionIndex: EnumSection): ContentForHe
4646
// Sanity onl uses th last pat of the slug.
4747
// `/welcome/news` becomes `news`.
4848
const slug = section.slug.split('/').pop();
49+
4950
return (
5051
useSanity(
5152
`*[_type=="pages"][slug.current==${JSON.stringify(slug)}][0]{

src/components/Matomo/consent.tsx

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import Link from 'next/link';
2+
import { classNames } from '@/util/utils';
3+
4+
interface CookieNoticeProps {
5+
isOpen: boolean;
6+
onAccept: () => void;
7+
onDecline: () => void;
8+
}
9+
10+
export function CookieNotice({ isOpen, onAccept, onDecline }: CookieNoticeProps) {
11+
return (
12+
<div
13+
className={classNames(
14+
'fixed bottom-3 left-3 z-50 items-center justify-center p-4',
15+
isOpen ? 'flex' : 'hidden'
16+
)}
17+
>
18+
<div className="relative w-full max-w-xl rounded-2xl bg-white shadow-lg">
19+
<div className="p-6">
20+
<h2 className="mb-4 text-2xl font-bold text-primary-8">Cookie notice</h2>
21+
22+
<p className="mb-6 select-none font-semibold text-primary-8">
23+
We use cookies to enhance your browsing experience, analyze website traffic with Matomo,
24+
and improve our services. Matomo helps us understand how you interact with our site
25+
while respecting your privacy—your data remains on our servers and is not shared with
26+
third parties.
27+
</p>
28+
<div className="my-4 h-px w-full select-none bg-gray-200" />
29+
<p className="mb-4 font-semibold text-primary-8">
30+
You can choose to accept or decline tracking cookies. Essential cookies necessary for
31+
the website to function will always be used.
32+
</p>
33+
34+
<div className="flex w-full items-center gap-3">
35+
<button
36+
type="button"
37+
aria-label="accept all cookies"
38+
onClick={onAccept}
39+
className={classNames(
40+
'max-w-max flex-1 rounded-full border border-gray-300 px-5 py-3 font-medium text-primary-8',
41+
'transition-colors hover:bg-primary-8 hover:text-white'
42+
)}
43+
>
44+
Accept all cookies
45+
</button>
46+
<button
47+
type="button"
48+
aria-label="decline tracking"
49+
onClick={onDecline}
50+
className={classNames(
51+
'max-w-max flex-1 rounded-full border border-gray-300 px-5 py-3 font-medium text-primary-8',
52+
'transition-colors hover:bg-primary-8 hover:text-white'
53+
)}
54+
>
55+
Decline tracking
56+
</button>
57+
</div>
58+
59+
<p className="mt-6 text-sm text-gray-400">
60+
For more details, visit our{' '}
61+
<Link href="/privacy" rel="noopener noreferrer" target="_blank" className="underline">
62+
Privacy Policy
63+
</Link>
64+
.
65+
</p>
66+
</div>
67+
</div>
68+
</div>
69+
);
70+
}

src/components/Matomo/index.tsx

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
'use client';
2+
3+
import { Suspense, useCallback, useEffect, useState } from 'react';
4+
import { usePathname, useSearchParams } from 'next/navigation';
5+
6+
import { CookieNotice } from '@/components/Matomo/consent';
7+
import { init, push } from '@/util/matomo';
8+
import { env } from '@/env.mjs';
9+
10+
const MATOMO_URL = env.NEXT_PUBLIC_MATOMO_URL;
11+
const MATOMO_CDN_URL = env.NEXT_PUBLIC_MATOMO_CDN_URL;
12+
const MATOMO_SITE_ID = env.NEXT_PUBLIC_MATOMO_SITE_ID;
13+
const CONSENT_SID = 'c_sid';
14+
15+
function Matomo() {
16+
const searchParams = useSearchParams();
17+
const pathname = usePathname();
18+
const [isOpen, setIsOpen] = useState(false);
19+
20+
const searchParamsString = searchParams.toString();
21+
const openConsent = useCallback(() => setIsOpen(true), []);
22+
23+
const onAccept = () => {
24+
if (MATOMO_URL && MATOMO_SITE_ID && MATOMO_CDN_URL) {
25+
init({
26+
url: MATOMO_URL,
27+
cdnUrl: MATOMO_CDN_URL,
28+
siteId: MATOMO_SITE_ID,
29+
disableCookies: false,
30+
});
31+
localStorage.setItem(
32+
CONSENT_SID,
33+
JSON.stringify({
34+
id: crypto.randomUUID(),
35+
lastUpdated: Math.floor(Date.now() / 1000),
36+
r: true,
37+
})
38+
);
39+
setIsOpen(false);
40+
}
41+
};
42+
43+
const onDecline = () => {
44+
localStorage.setItem(
45+
CONSENT_SID,
46+
JSON.stringify({
47+
id: crypto.randomUUID(),
48+
lastUpdated: Math.floor(Date.now() / 1000),
49+
r: false,
50+
})
51+
);
52+
setIsOpen(false);
53+
};
54+
55+
useEffect(() => {
56+
const savedConsent = localStorage.getItem(CONSENT_SID);
57+
if (!savedConsent) openConsent();
58+
}, [openConsent]);
59+
60+
useEffect(() => {
61+
if (!pathname) return;
62+
const url = `${pathname}${searchParamsString ? '?' + decodeURIComponent(searchParamsString) : ''}`;
63+
push(['setCustomUrl', url]);
64+
push(['trackPageView']);
65+
}, [pathname, searchParamsString]);
66+
67+
useEffect(() => {
68+
try {
69+
const savedConsent = localStorage.getItem(CONSENT_SID);
70+
if (savedConsent) {
71+
const consentParsed = JSON.parse(savedConsent);
72+
if (consentParsed.r) {
73+
if (MATOMO_URL && MATOMO_SITE_ID && MATOMO_CDN_URL) {
74+
init({
75+
url: MATOMO_URL,
76+
cdnUrl: MATOMO_CDN_URL,
77+
siteId: MATOMO_SITE_ID,
78+
disableCookies: false,
79+
});
80+
}
81+
}
82+
}
83+
} catch (error) {
84+
// eslint-disable-next-line no-console
85+
console.error('failed to parse the consent');
86+
}
87+
}, []);
88+
89+
return <CookieNotice isOpen={isOpen} onAccept={onAccept} onDecline={onDecline} />;
90+
}
91+
92+
export default function MatomoAnalyticsConsent() {
93+
return (
94+
<Suspense>
95+
<Matomo />
96+
</Suspense>
97+
);
98+
}

src/components/VirtualLab/Billing/Balance.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ export function BalanceDetails({ virtualLabId }: Props) {
279279
return null;
280280
}
281281

282+
// @ts-expect-error
282283
const { budget, total_spent: totalSpent } = balanceResult.data;
283284

284285
return (

src/components/VirtualLab/VirtualLabHomePage/index.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { virtualLabDetailAtomFamily } from '@/state/virtual-lab/lab';
77

88
export default function VirtualLabHome({ id }: { id: string }) {
99
const vlab = useUnwrappedValue(virtualLabDetailAtomFamily(id));
10+
1011
return (
1112
<>
1213
<WelcomeUserBanner title={vlab?.name} />

0 commit comments

Comments
 (0)