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
+2
View File
@@ -46,6 +46,8 @@ jobs:
- name: Build public site - name: Build public site
if: steps.publication.outputs.ready == 'true' if: steps.publication.outputs.ready == 'true'
env:
VITE_GA_MEASUREMENT_ID: ${{ secrets.GA_MEASUREMENT_ID }}
run: npm run build:pages run: npm run build:pages
- name: Publish generated files - name: Publish generated files
+5 -1
View File
@@ -1,9 +1,13 @@
--- ---
updatedAt: 2026-08-31T14:15:17+09:00 updatedAt: 2026-08-31T14:45:00+09:00
--- ---
## 2026년 8월 31일 ## 2026년 8월 31일
### 선택형 방문 통계 추가
`NEW` Google Analytics 방문 통계는 사용자가 동의한 뒤에만 활성화되며, 분석 설정에서 언제든 거부할 수 있습니다. 보유 카드, 편성, 검색어와 계산값은 전송하지 않습니다.
### 업데이트 내역 탭 추가 ### 업데이트 내역 탭 추가
`NEW` 버전 배포를 기다리지 않고, 수시로 반영되는 기능·개선·수정·데이터 변경 내용을 한곳에서 확인할 수 있습니다. `NEW` 버전 배포를 기다리지 않고, 수시로 반영되는 기능·개선·수정·데이터 변경 내용을 한곳에서 확인할 수 있습니다.
+3
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import AnalyticsConsent from '../src/lib/components/AnalyticsConsent.svelte';
import HomePage from '../src/routes/+page.svelte'; import HomePage from '../src/routes/+page.svelte';
import CardsPage from '../src/routes/cards/+page.svelte'; import CardsPage from '../src/routes/cards/+page.svelte';
import AnalysisPage from '../src/routes/analysis/+page.svelte'; import AnalysisPage from '../src/routes/analysis/+page.svelte';
@@ -108,6 +109,8 @@
<svelte:component this={CurrentPage} /> <svelte:component this={CurrentPage} />
<AnalyticsConsent pagePath={currentPath} />
<style> <style>
:global(*) { box-sizing: border-box; } :global(*) { box-sizing: border-box; }
:global(body) { margin: 0; background: #101319; color: #e8ebf1; font-family: Pretendard, "Noto Sans KR", sans-serif; } :global(body) { margin: 0; background: #101319; color: #e8ebf1; font-family: Pretendard, "Noto Sans KR", sans-serif; }
+9
View File
@@ -0,0 +1,9 @@
import type { AnalyticsClient } from '../src/lib/analytics/types.ts';
export const analytics: AnalyticsClient = {
available: false,
readConsent: () => null,
grantConsent: () => {},
denyConsent: () => {},
trackPageView: () => {}
};
+3
View File
@@ -0,0 +1,3 @@
import { createGoogleAnalytics } from '../src/lib/analytics/google-analytics.ts';
export const analytics = createGoogleAnalytics(import.meta.env.VITE_GA_MEASUREMENT_ID);
+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>
+3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import AnalyticsConsent from '$lib/components/AnalyticsConsent.svelte';
import { events } from '$lib/data/events.ts'; import { events } from '$lib/data/events.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts'; import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { activeEventAt } from '$lib/engine/event-optimization.ts'; import { activeEventAt } from '$lib/engine/event-optimization.ts';
@@ -62,6 +63,8 @@
<slot /> <slot />
<AnalyticsConsent pagePath={`${$page.url.pathname}${$page.url.search}`} />
<style> <style>
:global(*) { box-sizing: border-box; } :global(*) { box-sizing: border-box; }
:global(body) { margin: 0; background: #101319; color: #e8ebf1; font-family: Pretendard, "Noto Sans KR", sans-serif; } :global(body) { margin: 0; background: #101319; color: #e8ebf1; font-family: Pretendard, "Noto Sans KR", sans-serif; }
+1
View File
@@ -12,6 +12,7 @@ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],
resolve: { resolve: {
alias: [ alias: [
{ find: '$lib/analytics.ts', replacement: path.join(root, 'offline/analytics.ts') },
{ find: '$app/stores', replacement: path.join(root, 'offline/page-store.ts') }, { find: '$app/stores', replacement: path.join(root, 'offline/page-store.ts') },
{ find: '$app/environment', replacement: path.join(root, 'offline/app-environment.ts') }, { find: '$app/environment', replacement: path.join(root, 'offline/app-environment.ts') },
{ find: '$lib/data/chart-loader.ts', replacement: path.join(root, 'offline/chart-loader.ts') }, { find: '$lib/data/chart-loader.ts', replacement: path.join(root, 'offline/chart-loader.ts') },
+1
View File
@@ -12,6 +12,7 @@ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],
resolve: { resolve: {
alias: [ alias: [
{ find: '$lib/analytics.ts', replacement: path.join(root, 'pages/analytics.ts') },
{ find: '$app/stores', replacement: path.join(root, 'offline/page-store.ts') }, { find: '$app/stores', replacement: path.join(root, 'offline/page-store.ts') },
{ find: '$app/environment', replacement: path.join(root, 'offline/app-environment.ts') }, { find: '$app/environment', replacement: path.join(root, 'offline/app-environment.ts') },
{ find: '$lib/data/chart-loader.ts', replacement: path.join(root, 'offline/chart-loader.ts') }, { find: '$lib/data/chart-loader.ts', replacement: path.join(root, 'offline/chart-loader.ts') },