feat: add consent-based GA4 analytics
Publish GitHub Pages output / publish (push) Successful in 41s

This commit is contained in:
2026-08-31 14:42:59 +09:00
parent b48a3b4f9d
commit f5d9bef547
12 changed files with 256 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
import { env } from '$env/dynamic/public';
import { createGoogleAnalytics } from '$lib/analytics/google-analytics.ts';
export const analytics = createGoogleAnalytics(env.PUBLIC_GA_MEASUREMENT_ID);
+105
View File
@@ -0,0 +1,105 @@
import type { AnalyticsClient, AnalyticsConsent } from './types.ts';
const CONSENT_STORAGE_KEY = 'holodori:analytics-consent';
const MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]+$/i;
type GoogleTagWindow = Window & {
dataLayer?: unknown[][];
gtag?: (...args: unknown[]) => void;
};
function browserWindow(): GoogleTagWindow | null {
return typeof window === 'undefined' ? null : window as GoogleTagWindow;
}
function readStoredConsent(): AnalyticsConsent {
const currentWindow = browserWindow();
if (!currentWindow) return null;
try {
const stored = currentWindow.localStorage.getItem(CONSENT_STORAGE_KEY);
return stored === 'granted' || stored === 'denied' ? stored : null;
} catch {
return null;
}
}
function writeStoredConsent(consent: Exclude<AnalyticsConsent, null>) {
const currentWindow = browserWindow();
if (!currentWindow) return;
try {
currentWindow.localStorage.setItem(CONSENT_STORAGE_KEY, consent);
} catch {
// 저장소를 사용할 수 없어도 현재 페이지의 분석 설정은 계속 적용한다.
}
}
function removeGoogleAnalyticsCookies() {
if (typeof document === 'undefined') return;
for (const cookie of document.cookie.split(';')) {
const name = cookie.split('=', 1)[0]?.trim();
if (name === '_ga' || name?.startsWith('_ga_')) {
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
}
}
}
export function createGoogleAnalytics(rawMeasurementId: string | undefined): AnalyticsClient {
const measurementId = rawMeasurementId?.trim() ?? '';
const available = MEASUREMENT_ID_PATTERN.test(measurementId);
let loaded = false;
let sessionConsent: AnalyticsConsent = null;
const readConsent = () => sessionConsent ?? readStoredConsent();
const load = () => {
const currentWindow = browserWindow();
if (!available || !currentWindow || loaded) return;
currentWindow.dataLayer ??= [];
currentWindow.gtag ??= (...args: unknown[]) => currentWindow.dataLayer?.push(args);
currentWindow.gtag('consent', 'default', {
analytics_storage: 'granted',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied'
});
currentWindow.gtag('js', new Date());
currentWindow.gtag('config', measurementId, {
send_page_view: false,
allow_google_signals: false,
allow_ad_personalization_signals: false
});
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`;
script.dataset.holodoriAnalytics = 'true';
document.head.append(script);
loaded = true;
};
return {
available,
readConsent,
grantConsent() {
sessionConsent = 'granted';
writeStoredConsent('granted');
load();
},
denyConsent() {
sessionConsent = 'denied';
writeStoredConsent('denied');
removeGoogleAnalyticsCookies();
},
trackPageView(path: string) {
const currentWindow = browserWindow();
if (!available || !currentWindow || readConsent() !== 'granted') return;
load();
currentWindow.gtag?.('event', 'page_view', {
page_path: path,
page_location: new URL(path, currentWindow.location.origin).href,
page_title: document.title
});
}
};
}
+9
View File
@@ -0,0 +1,9 @@
export type AnalyticsConsent = 'granted' | 'denied' | null;
export interface AnalyticsClient {
readonly available: boolean;
readConsent(): AnalyticsConsent;
grantConsent(): void;
denyConsent(): void;
trackPageView(path: string): void;
}
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { onMount } from 'svelte';
import { analytics } from '$lib/analytics.ts';
export let pagePath: string;
let mounted = false;
let dialogOpen = false;
let lastTrackedPath = '';
onMount(() => {
mounted = true;
dialogOpen = analytics.available && analytics.readConsent() === null;
});
$: if (mounted && analytics.readConsent() === 'granted' && pagePath !== lastTrackedPath) {
analytics.trackPageView(pagePath);
lastTrackedPath = pagePath;
}
function grantConsent() {
analytics.grantConsent();
dialogOpen = false;
analytics.trackPageView(pagePath);
lastTrackedPath = pagePath;
}
function denyConsent() {
analytics.denyConsent();
dialogOpen = false;
lastTrackedPath = '';
}
</script>
{#if analytics.available}
{#if dialogOpen}
<div class="analytics-consent" role="dialog" aria-labelledby="analytics-consent-title" aria-describedby="analytics-consent-description">
<div class="consent-copy">
<strong id="analytics-consent-title">방문 통계 수집</strong>
<p id="analytics-consent-description">
서비스 개선을 위해 페이지 방문 경로와 기기·지역의 대략적인 통계를 Google Analytics로 수집합니다.
보유 카드, 편성, 검색어와 계산값은 전송하지 않습니다.
</p>
<a href="https://policies.google.com/privacy" target="_blank" rel="noreferrer">Google 개인정보처리방침 ↗</a>
</div>
<div class="consent-actions">
<button type="button" class="secondary" on:click={denyConsent}>거부</button>
<button type="button" class="primary" on:click={grantConsent}>동의</button>
</div>
</div>
{:else}
<button type="button" class="analytics-settings" on:click={() => dialogOpen = true}>분석 설정</button>
{/if}
{/if}
<style>
.analytics-consent {
position: fixed;
z-index: 100;
right: 18px;
bottom: 18px;
display: flex;
width: min(620px, calc(100% - 36px));
align-items: flex-end;
gap: 20px;
padding: 18px;
border: 1px solid rgb(239 139 182 / 45%);
border-radius: 14px;
background: rgb(24 28 36 / 97%);
box-shadow: 0 18px 50px rgb(0 0 0 / 42%);
color: #e8ebf1;
}
.consent-copy { min-width: 0; flex: 1; }
strong { display: block; margin-bottom: 6px; color: #f4c0d6; font-size: 15px; }
p { margin: 0 0 7px; color: #c3cad5; font-size: 13px; line-height: 1.55; }
a { color: #aeb9ca; font-size: 12px; }
.consent-actions { display: flex; flex: 0 0 auto; gap: 8px; }
button {
min-height: 38px;
padding: 8px 14px;
border: 1px solid transparent;
border-radius: 9px;
font: inherit;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
button:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 2px; }
.primary { color: #21121a; background: #ef8bb6; }
.secondary { border-color: #596273; color: #d4dae4; background: #303641; }
.analytics-settings {
position: fixed;
z-index: 90;
right: 12px;
bottom: 12px;
min-height: 30px;
padding: 5px 9px;
border-color: rgb(255 255 255 / 12%);
color: #8e98a8;
background: rgb(25 29 37 / 88%);
font-size: 11px;
}
@media (max-width: 560px) {
.analytics-consent { right: 12px; bottom: 12px; width: calc(100% - 24px); align-items: stretch; flex-direction: column; gap: 14px; }
.consent-actions { justify-content: flex-end; }
}
</style>