feat: add consent-based GA4 analytics
Publish GitHub Pages output / publish (push) Successful in 41s
Publish GitHub Pages output / publish (push) Successful in 41s
This commit is contained in:
@@ -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
@@ -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` 버전 배포를 기다리지 않고, 수시로 반영되는 기능·개선·수정·데이터 변경 내용을 한곳에서 확인할 수 있습니다.
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { AnalyticsClient } from '../src/lib/analytics/types.ts';
|
||||||
|
|
||||||
|
export const analytics: AnalyticsClient = {
|
||||||
|
available: false,
|
||||||
|
readConsent: () => null,
|
||||||
|
grantConsent: () => {},
|
||||||
|
denyConsent: () => {},
|
||||||
|
trackPageView: () => {}
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createGoogleAnalytics } from '../src/lib/analytics/google-analytics.ts';
|
||||||
|
|
||||||
|
export const analytics = createGoogleAnalytics(import.meta.env.VITE_GA_MEASUREMENT_ID);
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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') },
|
||||||
|
|||||||
@@ -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') },
|
||||||
|
|||||||
Reference in New Issue
Block a user