Skip to content

Commit 26ded57

Browse files
committed
Add Google Analytics with consent banner
1 parent 80d2bf7 commit 26ded57

5 files changed

Lines changed: 456 additions & 0 deletions

File tree

ga4-analytics.patch

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
2+
index dc15831..b2c1cc2 100644
3+
--- a/src/components/Footer.astro
4+
+++ b/src/components/Footer.astro
5+
@@ -7,5 +7,9 @@ const today = new Date();
6+
<p>
7+
&copy; {today.getFullYear()} Vincenzo Di Perna. All rights reserved.
8+
</p>
9+
+
10+
+ <a class="link link-hover" href="/privacy">
11+
+ Privacy & analytics
12+
+ </a>
13+
</div>
14+
</footer>
15+
diff --git a/src/components/GoogleAnalytics.astro b/src/components/GoogleAnalytics.astro
16+
new file mode 100644
17+
index 0000000..12d8eca
18+
--- /dev/null
19+
+++ b/src/components/GoogleAnalytics.astro
20+
@@ -0,0 +1,153 @@
21+
+---
22+
+// Replace this placeholder with your Google Analytics 4 Measurement ID.
23+
+// Example: const GA_MEASUREMENT_ID = "G-ABC123DE45";
24+
+const GA_MEASUREMENT_ID = "G-XXXXXXXXXX";
25+
+const isConfigured = GA_MEASUREMENT_ID.startsWith("G-") && GA_MEASUREMENT_ID !== "G-XXXXXXXXXX";
26+
+---
27+
+
28+
+{isConfigured && (
29+
+ <>
30+
+ <div
31+
+ id="analytics-consent-banner"
32+
+ class="fixed inset-x-0 bottom-0 z-[10000] hidden border-t border-base-300 bg-base-100/95 px-4 py-4 shadow-2xl backdrop-blur md:px-6"
33+
+ role="region"
34+
+ aria-label="Analytics consent"
35+
+ >
36+
+ <div class="mx-auto flex max-w-5xl flex-col gap-3 md:flex-row md:items-center md:justify-between">
37+
+ <div class="text-sm leading-relaxed text-base-content">
38+
+ <p class="font-semibold">Privacy-friendly analytics</p>
39+
+ <p class="opacity-80">
40+
+ I use Google Analytics to understand aggregate site usage, file downloads,
41+
+ and outbound clicks. Analytics loads only if you accept.
42+
+ </p>
43+
+ </div>
44+
+
45+
+ <div class="flex shrink-0 gap-2">
46+
+ <button id="analytics-consent-reject" class="btn btn-sm btn-outline" type="button">
47+
+ Reject
48+
+ </button>
49+
+ <button id="analytics-consent-accept" class="btn btn-sm btn-primary" type="button">
50+
+ Accept analytics
51+
+ </button>
52+
+ </div>
53+
+ </div>
54+
+ </div>
55+
+
56+
+ <script define:vars={{ GA_MEASUREMENT_ID }}>
57+
+ (() => {
58+
+ if (window.__vindipeGoogleAnalyticsInitialized) return;
59+
+ window.__vindipeGoogleAnalyticsInitialized = true;
60+
+
61+
+ const measurementId = GA_MEASUREMENT_ID;
62+
+ const consentKey = "vindipe_analytics_consent";
63+
+ const acceptedValue = "accepted";
64+
+ const rejectedValue = "rejected";
65+
+ let analyticsLoaded = false;
66+
+
67+
+ const isLocalHost = ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname);
68+
+
69+
+ function getConsent() {
70+
+ try {
71+
+ return window.localStorage.getItem(consentKey);
72+
+ } catch {
73+
+ return null;
74+
+ }
75+
+ }
76+
+
77+
+ function setConsent(value) {
78+
+ try {
79+
+ window.localStorage.setItem(consentKey, value);
80+
+ } catch {
81+
+ // If localStorage is unavailable, continue without persisting consent.
82+
+ }
83+
+ }
84+
+
85+
+ function hideBanner() {
86+
+ document.getElementById("analytics-consent-banner")?.classList.add("hidden");
87+
+ }
88+
+
89+
+ function showBanner() {
90+
+ document.getElementById("analytics-consent-banner")?.classList.remove("hidden");
91+
+ }
92+
+
93+
+ function trackPageView() {
94+
+ if (typeof window.gtag !== "function") return;
95+
+
96+
+ window.gtag("event", "page_view", {
97+
+ page_title: document.title,
98+
+ page_location: window.location.href,
99+
+ page_path: window.location.pathname + window.location.search,
100+
+ });
101+
+ }
102+
+
103+
+ function loadGoogleAnalytics() {
104+
+ if (analyticsLoaded || isLocalHost) return;
105+
+ analyticsLoaded = true;
106+
+
107+
+ window.dataLayer = window.dataLayer || [];
108+
+ window.gtag = function gtag(){ window.dataLayer.push(arguments); };
109+
+
110+
+ window.gtag("consent", "default", {
111+
+ analytics_storage: "granted",
112+
+ ad_storage: "denied",
113+
+ ad_user_data: "denied",
114+
+ ad_personalization: "denied",
115+
+ functionality_storage: "granted",
116+
+ security_storage: "granted",
117+
+ });
118+
+
119+
+ window.gtag("js", new Date());
120+
+ window.gtag("config", measurementId, {
121+
+ send_page_view: false,
122+
+ });
123+
+
124+
+ const script = document.createElement("script");
125+
+ script.async = true;
126+
+ script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`;
127+
+ script.onload = trackPageView;
128+
+ document.head.appendChild(script);
129+
+ }
130+
+
131+
+ function bindConsentButtons() {
132+
+ document.getElementById("analytics-consent-accept")?.addEventListener("click", () => {
133+
+ setConsent(acceptedValue);
134+
+ hideBanner();
135+
+ loadGoogleAnalytics();
136+
+ });
137+
+
138+
+ document.getElementById("analytics-consent-reject")?.addEventListener("click", () => {
139+
+ setConsent(rejectedValue);
140+
+ hideBanner();
141+
+ });
142+
+ }
143+
+
144+
+ function initAnalyticsConsent() {
145+
+ bindConsentButtons();
146+
+
147+
+ const consent = getConsent();
148+
+ if (consent === acceptedValue) {
149+
+ hideBanner();
150+
+ loadGoogleAnalytics();
151+
+ return;
152+
+ }
153+
+
154+
+ if (consent === rejectedValue || isLocalHost) {
155+
+ hideBanner();
156+
+ return;
157+
+ }
158+
+
159+
+ showBanner();
160+
+ }
161+
+
162+
+ initAnalyticsConsent();
163+
+
164+
+ document.addEventListener("astro:page-load", () => {
165+
+ if (getConsent() === acceptedValue) {
166+
+ loadGoogleAnalytics();
167+
+ trackPageView();
168+
+ }
169+
+ });
170+
+ })();
171+
+ </script>
172+
+ </>
173+
+)}
174+
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
175+
index f699ab2..5b3b80e 100644
176+
--- a/src/layouts/BaseLayout.astro
177+
+++ b/src/layouts/BaseLayout.astro
178+
@@ -4,6 +4,7 @@ import Header from "../components/Header.astro";
179+
import Footer from "../components/Footer.astro";
180+
import SideBar from "../components/SideBar.astro";
181+
import NetworkBackground from "../components/NetworkBackground.astro";
182+
+import GoogleAnalytics from "../components/GoogleAnalytics.astro";
183+
import { ViewTransitions } from "astro:transitions";
184+
185+
import { SITE_TITLE, SITE_DESCRIPTION, TRANSITION_API } from "../config";
186+
@@ -49,5 +50,7 @@ const imageURL = new URL(image, site).toString();
187+
188+
{includeSidebar && <SideBar sideBarActiveItemID={sideBarActiveItemID} />}
189+
</div>
190+
+
191+
+ <GoogleAnalytics />
192+
</body>
193+
</html>
194+
\ No newline at end of file
195+
diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro
196+
new file mode 100644
197+
index 0000000..0f7fe95
198+
--- /dev/null
199+
+++ b/src/pages/privacy.astro
200+
@@ -0,0 +1,48 @@
201+
+---
202+
+import BaseLayout from "../layouts/BaseLayout.astro";
203+
+---
204+
+
205+
+<BaseLayout
206+
+ title="Privacy & analytics | Vincenzo Di Perna"
207+
+ description="Privacy and analytics information for the personal website of Vincenzo Di Perna."
208+
+>
209+
+ <section class="mb-10">
210+
+ <h1 class="text-4xl font-bold mb-4">Privacy & analytics</h1>
211+
+
212+
+ <div class="prose max-w-none leading-relaxed">
213+
+ <p>
214+
+ This is the personal website of Vincenzo Di Perna. The site is hosted as
215+
+ a static website and is designed to collect only the information needed
216+
+ to understand whether the website is useful to visitors.
217+
+ </p>
218+
+
219+
+ <h2>Analytics</h2>
220+
+
221+
+ <p>
222+
+ This site may use Google Analytics 4 to collect aggregate usage statistics,
223+
+ such as page views, file downloads, outbound link clicks, approximate
224+
+ geography, device category, browser, and referral sources.
225+
+ </p>
226+
+
227+
+ <p>
228+
+ Analytics is loaded only after you accept the analytics banner. If you
229+
+ reject analytics, the Google Analytics tag is not loaded by this site.
230+
+ </p>
231+
+
232+
+ <h2>Downloads and outbound links</h2>
233+
+
234+
+ <p>
235+
+ Analytics may record aggregate events such as opening the CV PDF, opening
236+
+ thesis PDFs, or clicking external links to GitHub, Zenodo, DOI pages,
237+
+ LinkedIn, or related research resources.
238+
+ </p>
239+
+
240+
+ <h2>Contact</h2>
241+
+
242+
+ <p>
243+
+ If you contact me by email, the information you send is processed only
244+
+ for communication purposes.
245+
+ </p>
246+
+ </div>
247+
+ </section>
248+
+</BaseLayout>

src/components/Footer.astro

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,9 @@ const today = new Date();
77
<p>
88
&copy; {today.getFullYear()} Vincenzo Di Perna. All rights reserved.
99
</p>
10+
11+
<a class="link link-hover" href="/privacy">
12+
Privacy & analytics
13+
</a>
1014
</div>
1115
</footer>

0 commit comments

Comments
 (0)