feat(web): add privacy-friendly Umami custom events
Instrument the play flow with 9 content-free custom events (game_start, art_style_select, style_image_upload, scene_reached, choice_select, vision_click, tts_toggle, fullscreen_toggle, play_heartbeat) to measure retention, engagement depth and session duration. Privacy is enforced by construction, not convention: - lib/analytics.ts types each event with a discriminated union, so a payload has no slot for free text — prompts, world guides, uploaded images and vision output can never reach analytics (compile-time guarantee, not a comment). - track() no-ops without window.umami and never throws into the app. - coarse 30s heartbeat fires only while the tab is visible. - script stays gated on NEXT_PUBLIC_UMAMI_* env (blank → no script), honours Do-Not-Track, and locks to an exact data-domains allowlist. - one-line on-site disclosure with a link, shown only when tracking is on. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+42
-3
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { track } from "@/lib/analytics";
|
||||
|
||||
/* ============================================================================
|
||||
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
|
||||
@@ -1008,9 +1009,11 @@ function StyleModal({
|
||||
// 用户事后还可以手动改 draft(仍是 textarea)。
|
||||
setDraft(data.stylePrompt);
|
||||
setCustomStyleRefImage(resized);
|
||||
track("style_image_upload", { ok: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "解析失败";
|
||||
setParseError(msg);
|
||||
track("style_image_upload", { ok: false });
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
@@ -1524,6 +1527,17 @@ export default function HomePage() {
|
||||
const styleReferenceImage =
|
||||
artStyle === "自定义" && customStyleRefImage ? customStyleRefImage : undefined;
|
||||
|
||||
track("game_start", {
|
||||
source: "prompt",
|
||||
gender,
|
||||
art_style: artStyle,
|
||||
plot_style: plotStyle,
|
||||
pacing: pace,
|
||||
tts: audioEnabled,
|
||||
has_prompt: prompt.trim().length > 0,
|
||||
has_style_ref: Boolean(styleReferenceImage),
|
||||
});
|
||||
|
||||
sessionStorage.setItem(
|
||||
"infiplot:custom",
|
||||
JSON.stringify({ worldSetting, styleGuide, audioEnabled, styleReferenceImage }),
|
||||
@@ -1533,6 +1547,9 @@ export default function HomePage() {
|
||||
|
||||
const stories = STORIES[galleryGender];
|
||||
const imgPrefix = galleryGender === "女性向" ? "f" : "m";
|
||||
const analyticsOn = Boolean(
|
||||
process.env.NEXT_PUBLIC_UMAMI_SRC && process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
);
|
||||
|
||||
// 点卡片 = 直接开始这张卡的故事,零等待:跳 /play?card=m0/f0... 由 /play
|
||||
// 页面从 /home/firstact/{name}.json 静态文件加载预烘焙好的首幕(含 scene /
|
||||
@@ -1547,6 +1564,12 @@ export default function HomePage() {
|
||||
"infiplot:custom",
|
||||
JSON.stringify({ worldSetting: "", styleGuide: "", audioEnabled }),
|
||||
);
|
||||
track("game_start", {
|
||||
source: "curated",
|
||||
gender: galleryGender,
|
||||
tts: audioEnabled,
|
||||
card: `${imgPrefix}${idx}`,
|
||||
});
|
||||
router.push(`/play?card=${imgPrefix}${idx}`);
|
||||
};
|
||||
|
||||
@@ -1778,8 +1801,21 @@ export default function HomePage() {
|
||||
目前,内测期间生成的内容不会被保存,如有需要,请通过录屏或截图等方式保存游玩体验,并记录下生成故事时的提示词与风格选项等。
|
||||
<br />
|
||||
AI 生成的内容不代表本团队立场。
|
||||
<br />
|
||||
本站使用开源的 Umami 进行隐私友好的匿名访问统计:不使用 Cookie、不收集个人信息、不做跨站追踪。
|
||||
{analyticsOn && (
|
||||
<>
|
||||
<br />
|
||||
本站使用开源的{" "}
|
||||
<a
|
||||
href="https://umami.is/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline decoration-clay-900/20 underline-offset-2 transition-colors hover:text-clay-700"
|
||||
>
|
||||
Umami
|
||||
</a>{" "}
|
||||
进行隐私友好的匿名访问与交互统计:不使用 Cookie、不收集个人信息、不发送任何您输入的内容、不做跨站追踪。
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -1794,7 +1830,10 @@ export default function HomePage() {
|
||||
<StyleModal
|
||||
items={OPTS[styleRow]!.items}
|
||||
value={sel[styleRow] ?? 0}
|
||||
onPick={(i) => setSel((s) => s.map((v, j) => (j === styleRow ? i : v)))}
|
||||
onPick={(i) => {
|
||||
track("art_style_select", { style: OPTS[styleRow]!.items[i] ?? String(i) });
|
||||
setSel((s) => s.map((v, j) => (j === styleRow ? i : v)));
|
||||
}}
|
||||
onClose={() => setStyleOpen(false)}
|
||||
customStyleGuide={customStyleGuide}
|
||||
setCustomStyleGuide={setCustomStyleGuide}
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
StartResponse,
|
||||
VisionResponse,
|
||||
} from "@infiplot/types";
|
||||
import { track } from "@/lib/analytics";
|
||||
|
||||
const MUTED_STORAGE_KEY = "infiplot:muted";
|
||||
|
||||
@@ -373,6 +374,17 @@ function PlayInner() {
|
||||
mutedRef.current = muted;
|
||||
}, [muted]);
|
||||
|
||||
// Coarse liveness ping for active-time analytics. /play is a single SPA
|
||||
// route, so page views alone read as ~0 duration; a 30s heartbeat (only
|
||||
// while the tab is visible) gives Umami the timestamps to derive real
|
||||
// engaged time. Content-free — no payload, and a no-op without the tracker.
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") track("play_heartbeat");
|
||||
}, 30_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Whenever currentBeatId changes, append it to visited (skip consecutive dups)
|
||||
useEffect(() => {
|
||||
if (!currentBeatId) return;
|
||||
@@ -472,6 +484,7 @@ function PlayInner() {
|
||||
|
||||
// ── Mute persistence (read is via the useState lazy initializer above) ─
|
||||
const toggleMuted = useCallback(() => {
|
||||
track("tts_toggle", { muted: !mutedRef.current });
|
||||
setMuted((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
@@ -507,6 +520,7 @@ function PlayInner() {
|
||||
// ── Presentation mode toggle ─────────────────────────────────────────
|
||||
const togglePresentation = useCallback(async () => {
|
||||
const entering = !presentation;
|
||||
track("fullscreen_toggle", { on: entering });
|
||||
if (entering) {
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
@@ -671,6 +685,7 @@ function PlayInner() {
|
||||
// beatAudioMap is populated lazily by the per-beat fetch effect once
|
||||
// currentScene becomes non-null (see fetchBeatAudio).
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: initial.history.length });
|
||||
})
|
||||
.catch((e) => setError(String(e)));
|
||||
}, [params, router]);
|
||||
@@ -782,6 +797,7 @@ function PlayInner() {
|
||||
// beatAudioMap reset + per-beat fetches kicked off by the scene effect.
|
||||
setLastExitLabel(exitLabel);
|
||||
setPhase("ready");
|
||||
track("scene_reached", { scene_index: newSession.history.length });
|
||||
} catch (e) {
|
||||
if ((e as { name?: string }).name === "AbortError") {
|
||||
setPhase("ready");
|
||||
@@ -795,6 +811,17 @@ function PlayInner() {
|
||||
function onSelectChoice(choice: BeatChoice) {
|
||||
if (phase !== "ready" || !session || !currentScene) return;
|
||||
|
||||
const beatNext = currentBeatRef.current?.next;
|
||||
const choiceIndex =
|
||||
beatNext?.type === "choice"
|
||||
? beatNext.choices.findIndex((c) => c.id === choice.id)
|
||||
: -1;
|
||||
track("choice_select", {
|
||||
scene_index: session.history.length,
|
||||
choice_index: choiceIndex,
|
||||
kind: choice.effect.kind,
|
||||
});
|
||||
|
||||
if (choice.effect.kind === "advance-beat") {
|
||||
// Pure local jump. No network. No pool changes.
|
||||
setCurrentBeatId(choice.effect.targetBeatId);
|
||||
@@ -863,6 +890,7 @@ function PlayInner() {
|
||||
throw new Error(j.error ?? visionRes.statusText);
|
||||
}
|
||||
const decision = (await visionRes.json()) as VisionResponse;
|
||||
track("vision_click", { result: decision.classify });
|
||||
|
||||
if (decision.classify === "insert-beat") {
|
||||
setPhase("inserting-beat");
|
||||
|
||||
Reference in New Issue
Block a user