diff --git a/docs/analytics/ga4-operations.md b/docs/analytics/ga4-operations.md
index 32dd0c0c..316e365c 100644
--- a/docs/analytics/ga4-operations.md
+++ b/docs/analytics/ga4-operations.md
@@ -33,6 +33,15 @@
운영자가 만드는 UTM 값에는 이름, 이메일, 전화번호, 검색어 등 개인을 식별하거나 사용자가 입력한 값을 넣지 않는다. 허용 키를 추가하려면 실제 분석 목적과 개인정보 처리방침의 수집 범위를 먼저 검토한다.
+## 초대 링크 캠페인 보존
+
+- 복사·기본 공유 시 외부 초대 링크에 `utm_source=member_invite`, `utm_medium=referral`, `utm_campaign=invite`를 붙인다. 플랫폼이나 초대한 개인을 구분하는 값이 아니다.
+- 링크에 기존 `utm_*`가 하나라도 있으면 빈 값·불완전한 캠페인도 그대로 두고 기본 캠페인을 채우거나 섞지 않는다.
+- 초대 진입 URL의 허용된 UTM만 초대 코드와 함께 같은 탭의 pending invite 문맥으로 보존한다. 로그인·약관 동의·오류 재시도와 인증 후 초대 복귀에 전달하고, 인증 완료의 복귀 주소를 만든 뒤 소비한다. 다른 초대가 들어오면 이전 문맥을 교체하며 인증된 초대 진입에서도 남은 문맥을 정리한다.
+- 이 저장은 초대 인증 이동의 URL 문맥이며 이벤트 큐가 아니다. 동의 전 이벤트를 저장하거나 소급 전송하지 않는다. 일반 로그인·다른 내부 탐색에는 기본 캠페인을 만들지 않는다.
+- OAuth state와 인증 토큰에 캠페인을 싣지 않는다. 원래 referrer 복원, 브라우저 간 연결, 초대 코드·초대자 ID의 분석 전송은 하지 않는다. 분석 URL 정규화와 User-ID 설정·해제는 기존 정책을 유지한다.
+- 외부 브라우저가 같은 탭 저장소를 공유한다고 가정하지 않는다. 이 코드만으로 GA 세션 귀속이나 신규 사용자 집계가 소급 변경되는 것은 보장하지 않는다. 인증 도메인 referral 제외 등 운영 설정은 별도 관리한다.
+
## 배포 환경 변수
프로덕션에는 운영 데이터 스트림의 측정 ID만 설정한다.
diff --git a/src/app/(main)/member-settings/_components/AddMemberPanel.analytics.test.tsx b/src/app/(main)/member-settings/_components/AddMemberPanel.analytics.test.tsx
index 109a470d..898bc265 100644
--- a/src/app/(main)/member-settings/_components/AddMemberPanel.analytics.test.tsx
+++ b/src/app/(main)/member-settings/_components/AddMemberPanel.analytics.test.tsx
@@ -45,3 +45,15 @@ it("does not record a rejected copy or cancelled native share", async () => {
}
expect(commands).not.toHaveBeenCalled();
});
+it.each(["복사", "공유"])("provides the same tagged external URL to %s", async label => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ const share = vi.fn().mockResolvedValue(undefined);
+ vi.stubGlobal("navigator", { clipboard: { writeText }, share });
+ await mount();
+ const button = renderer.root.findAllByType("button").find(b => b.children.includes(label))!;
+ await act(async () => { await button.props.onClick(); });
+ const url = "https://example.test/join/private-access-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite";
+ if (label === "복사") expect(writeText).toHaveBeenCalledWith(url);
+ else expect(share).toHaveBeenCalledWith({ title: "우때 여행 초대", url });
+ expect(JSON.stringify(commands.mock.calls)).not.toContain("private-access-code");
+});
diff --git a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx
index c8a91c7f..985e150b 100644
--- a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx
+++ b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx
@@ -1,5 +1,7 @@
"use client";
+import { buildSharedInviteUrl } from "@/lib/invite-campaign";
+
import { CopyCheckIcon, CopyIcon, RefreshIcon, ShareIcon } from "@/assets/icons";
import { Spinner } from "@/components/ui/spinner";
@@ -113,7 +115,7 @@ export function AddMemberPanel({
const displayedCode =
issuedCode === undefined ? detailInviteCode || null : issuedCode;
const inviteUrl = canShareInvite && displayedCode
- ? `${typeof window !== "undefined" ? window.location.origin : ""}/join/${displayedCode}`
+ ? buildSharedInviteUrl(`${typeof window !== "undefined" ? window.location.origin : ""}/join/${encodeURIComponent(displayedCode)}`)
: null;
async function handleCopy() {
diff --git a/src/app/auth/callback/page.tsx b/src/app/auth/callback/page.tsx
index 4ae95d5a..42c3374d 100644
--- a/src/app/auth/callback/page.tsx
+++ b/src/app/auth/callback/page.tsx
@@ -11,6 +11,7 @@ import {
loginErrorQueryForExchangeFailure,
saveGoogleAgreementFlowSession,
} from "@/lib/google-oauth";
+import { pendingInviteAuthPath } from "@/lib/session-user-flow";
import { finishGoogleLogin } from "@/lib/google-login-client";
function AuthCallbackContent() {
@@ -37,7 +38,7 @@ function AuthCallbackContent() {
!pending ||
returnedState !== pending.state
) {
- router.replace("/login?error=OAuthCallback");
+ router.replace(pendingInviteAuthPath("/login?error=OAuthCallback"));
return;
}
@@ -45,7 +46,7 @@ function AuthCallbackContent() {
.then(async (result) => {
if (result.ok && result.status === "AUTHENTICATED") {
const destination = await finishGoogleLogin(queryClient);
- router.replace(destination ?? "/login?error=OAuthCallback");
+ router.replace(destination ?? pendingInviteAuthPath("/login?error=OAuthCallback"));
return;
}
@@ -55,7 +56,7 @@ function AuthCallbackContent() {
signupToken: result.signupToken,
expiresAt: Date.now() + result.expiresInSeconds * 1000,
});
- router.replace("/login/agreements");
+ router.replace(pendingInviteAuthPath("/login/agreements"));
return;
}
@@ -65,15 +66,15 @@ function AuthCallbackContent() {
reacceptanceToken: result.reacceptanceToken,
expiresAt: Date.now() + result.expiresInSeconds * 1000,
});
- router.replace("/login/agreements");
+ router.replace(pendingInviteAuthPath("/login/agreements"));
return;
}
const errorQuery = loginErrorQueryForExchangeFailure(result.errorCode);
- router.replace(`/login?error=${errorQuery}`);
+ router.replace(pendingInviteAuthPath(`/login?error=${errorQuery}`));
})
.catch(() => {
- router.replace("/login?error=OAuthCallback");
+ router.replace(pendingInviteAuthPath("/login?error=OAuthCallback"));
});
}, [searchParams, router, queryClient]);
diff --git a/src/app/home/_components/RoomCardMenu.test.tsx b/src/app/home/_components/RoomCardMenu.test.tsx
index 0b278a1e..bd1e469c 100644
--- a/src/app/home/_components/RoomCardMenu.test.tsx
+++ b/src/app/home/_components/RoomCardMenu.test.tsx
@@ -71,7 +71,7 @@ it("opens the selected room's invite link without regenerating it and restores f
expect(state.detailId).toHaveBeenCalledWith("selected-room");
expect(state.regenerate).not.toHaveBeenCalled();
await click("복사");
- expect(state.copy).toHaveBeenCalledWith(`${window.location.origin}/join/existing-code`);
+ expect(state.copy).toHaveBeenCalledWith(`${window.location.origin}/join/existing-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite`);
expect(button("복사됨")).toBeDefined();
await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
await flush();
@@ -87,7 +87,7 @@ it("issues a link once when the room detail confirms no existing code", async ()
expect(state.regenerate.mock.calls[0][0]).toEqual({ roomId: "selected-room", trigger: "automatic" });
await act(async () => state.regenerate.mock.calls[0][1].onSuccess({ inviteCode: "first-code" }));
await click("복사");
- expect(state.copy).toHaveBeenCalledWith(`${window.location.origin}/join/first-code`);
+ expect(state.copy).toHaveBeenCalledWith(`${window.location.origin}/join/first-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite`);
});
it.each(["loading", "error"])("does not issue links while detail is %s", async (mode) => {
@@ -115,7 +115,7 @@ it.each(["loading", "error"])("does not issue links while detail is %s", async (
await render();
expect(button("다시 시도")).toBeUndefined();
await click("복사");
- expect(state.copy).toHaveBeenCalledExactlyOnceWith(`${window.location.origin}/join/recovered-code`);
+ expect(state.copy).toHaveBeenCalledExactlyOnceWith(`${window.location.origin}/join/recovered-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite`);
expect(state.regenerate).not.toHaveBeenCalled();
});
@@ -133,11 +133,11 @@ it("regenerates only on request and keeps the existing link when regeneration fa
expect(state.regenerate).toHaveBeenCalledTimes(1);
await act(async () => state.regenerate.mock.calls[0][1].onError());
await click("복사");
- expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/existing-code`);
+ expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/existing-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite`);
await click("초대 링크 재발급");
await act(async () => state.regenerate.mock.calls[1][1].onSuccess({ inviteCode: "new-code" }));
await click("복사");
- expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/new-code`);
+ expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/new-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite`);
});
it("blocks copying and sharing the old link while regeneration is pending", async () => {
@@ -152,7 +152,7 @@ it("blocks copying and sharing the old link while regeneration is pending", asyn
await act(async () => state.regenerate.mock.calls[0][1].onSuccess({ inviteCode: "new-code" }));
await render();
await click("복사");
- expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/new-code`);
+ expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/new-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite`);
});
it("preserves host deletion and member leave permissions", async () => {
@@ -173,7 +173,7 @@ it("lets a member copy and share the existing link without issuing or regenerati
await render("MEMBER");
await click("제주 여행 더보기"); await click("초대하기");
await click("복사");
- expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/existing-code`);
+ expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/existing-code?utm_source=member_invite&utm_medium=referral&utm_campaign=invite`);
await click("공유");
expect(state.copy).toHaveBeenCalledTimes(2);
expect(button("초대 링크 재발급")).toBeDefined();
diff --git a/src/app/invite-auth-flow.test.tsx b/src/app/invite-auth-flow.test.tsx
new file mode 100644
index 00000000..f0eeec5f
--- /dev/null
+++ b/src/app/invite-auth-flow.test.tsx
@@ -0,0 +1,112 @@
+// @vitest-environment jsdom
+import { useEffect, type ReactNode } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import JoinPage from "./join/[inviteCode]/page";
+import AuthCallbackPage from "./auth/callback/page";
+import LoginAgreementsPage from "./login/agreements/page";
+import { saveOAuthPendingSession, readGoogleAgreementFlowSession, buildGoogleAuthorizationUrl } from "@/lib/google-oauth";
+import { pendingInviteAuthPath, setPendingInviteCode, consumePendingInviteCode } from "@/lib/session-user-flow";
+
+const ports = vi.hoisted(() => ({
+ router: { replace: vi.fn() },
+ code: "test-invite",
+ exchange: vi.fn(), signup: vi.fn(), reaccept: vi.fn(), join: vi.fn(),
+ readUser: vi.fn(),
+}));
+vi.mock("next/navigation", () => ({
+ useRouter: () => ports.router,
+ useSearchParams: () => new URLSearchParams(window.location.search),
+ useParams: () => ({ inviteCode: ports.code }),
+}));
+vi.mock("@/lib/api/auth", () => ({
+ exchangeGoogleCode: ports.exchange,
+ completeGoogleSignup: ports.signup,
+ completeGoogleReaccept: ports.reaccept,
+}));
+vi.mock("@/lib/session-user", () => ({ fetchSessionUserWithRetry: ports.readUser }));
+vi.mock("@/hooks/useRooms", () => ({ useJoinRoom: () => ({ mutate: ports.join }) }));
+vi.mock("@/components/BrandLogo", () => ({ BrandLogo: () => null }));
+vi.mock("@/components/agreements/AgreementConsentSection", () => ({
+ AgreementConsentSection: ({ onStateChange }: { onStateChange: (state: { isLoading: boolean; isError: boolean; canProceed: boolean }) => void }) => {
+ useEffect(() => onStateChange({ isLoading: false, isError: false, canProceed: true }), [onStateChange]);
+ return null;
+ },
+}));
+let client: QueryClient;
+let renderer: ReactTestRenderer | undefined;
+async function mount(node: ReactNode, path: string) {
+ if (renderer) await act(async () => renderer?.unmount());
+ window.history.replaceState(null, "", path);
+ await act(async () => { renderer = create({node}); });
+}
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 401 })));
+ client = new QueryClient();
+ sessionStorage.clear();
+ ports.code = "test-invite";
+ ports.readUser.mockResolvedValue({ id: 42, email: "fixture@example.test", nickname: "fixture", profileImageUrl: null, provider: "GOOGLE", tutorialCompleted: false });
+ ports.signup.mockResolvedValue({ ok: true });
+ ports.reaccept.mockResolvedValue({ ok: true });
+});
+afterEach(async () => {
+ await act(async () => renderer?.unmount());
+ renderer = undefined;
+ client.clear();
+ vi.unstubAllGlobals(); vi.clearAllMocks();
+});
+const campaign = "utm_source=member_invite&utm_medium=referral&utm_campaign=invite";
+it("carries only incoming campaign parameters from the invite page to login", async () => {
+ await mount(, `/join/test-invite?${campaign}&code=secret&state=secret`);
+ expect(ports.router.replace).toHaveBeenCalledWith(`/login?${campaign}`);
+ expect(pendingInviteAuthPath("/login/agreements")).toBe(`/login/agreements?${campaign}`);
+});
+it.each(["signup", "reaccept"] as const)("carries the original partial campaign through callback, %s and invite return", async kind => {
+ setPendingInviteCode("test-invite", "?utm_campaign=existing");
+ saveOAuthPendingSession({ state: "expected-state" });
+ ports.exchange.mockResolvedValue(kind === "signup"
+ ? { ok: true, status: "SIGNUP_REQUIRED", signupToken: "test-signup", expiresInSeconds: 600 }
+ : { ok: true, status: "REACCEPTANCE_REQUIRED", reacceptanceToken: "test-reaccept", expiresInSeconds: 600 });
+ await mount(, "/auth/callback?code=test-code&state=expected-state");
+ expect(ports.exchange).toHaveBeenCalledExactlyOnceWith("test-code", undefined);
+ expect(ports.router.replace).toHaveBeenLastCalledWith("/login/agreements?utm_campaign=existing");
+ expect(readGoogleAgreementFlowSession()?.kind).toBe(kind);
+ await mount(, "/login/agreements?utm_campaign=existing");
+ const button = renderer!.root.findAllByType("button").find(b => b.children.includes(kind === "signup" ? "동의하고 가입하기" : "동의하고 계속하기"))!;
+ expect(button.props.disabled).toBe(false);
+ await act(async () => { button.props.onClick(); });
+ expect(ports.router.replace).toHaveBeenLastCalledWith("/join/test-invite?utm_campaign=existing");
+ expect(kind === "signup" ? ports.signup : ports.reaccept).toHaveBeenCalledExactlyOnceWith(kind === "signup" ? "test-signup" : "test-reaccept");
+ expect(consumePendingInviteCode()).toBeNull();
+ expect(pendingInviteAuthPath("/login")).toBe("/login");
+});
+it("keeps state validation before any code exchange and preserves retry context", async () => {
+ setPendingInviteCode("test-invite", `?${campaign}`);
+ saveOAuthPendingSession({ state: "expected-state" });
+ await mount(, "/auth/callback?code=test-code&state=wrong-state");
+ expect(ports.exchange).not.toHaveBeenCalled();
+ expect(ports.router.replace).toHaveBeenCalledWith(`/login?error=OAuthCallback&${campaign}`);
+ expect(sessionStorage.getItem("oauth_pending")).toBeNull();
+ expect(buildGoogleAuthorizationUrl()).not.toContain("utm_");
+});
+it("preserves retry context after exchange rejection", async () => {
+ setPendingInviteCode("test-invite", "?utm_source=partner");
+ saveOAuthPendingSession({ state: "expected-state" });
+ ports.exchange.mockRejectedValue(new Error("test network failure"));
+ await mount(, "/auth/callback?code=test-code&state=expected-state");
+ expect(ports.router.replace).toHaveBeenLastCalledWith("/login?error=OAuthCallback&utm_source=partner");
+});
+it("removes stale pending context when an authenticated user opens another invitation", async () => {
+ setPendingInviteCode("previous-invite", `?${campaign}`);
+ vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 200 }));
+ await mount(, "/join/test-invite");
+ expect(ports.join).toHaveBeenCalledWith("test-invite", expect.any(Object));
+ expect(consumePendingInviteCode()).toBeNull();
+ expect(pendingInviteAuthPath("/login")).toBe("/login");
+});
+it("keeps ordinary unauthenticated invitation navigation untagged", async () => {
+ await mount(, "/join/test-invite");
+ expect(ports.router.replace).toHaveBeenCalledWith("/login");
+});
diff --git a/src/app/join/[inviteCode]/page.tsx b/src/app/join/[inviteCode]/page.tsx
index 69615cc8..02c88ee3 100644
--- a/src/app/join/[inviteCode]/page.tsx
+++ b/src/app/join/[inviteCode]/page.tsx
@@ -8,6 +8,7 @@ import { Spinner } from "@/components/ui/spinner";
import { BrandLogo } from "@/components/BrandLogo";
import { checkClientAuthenticated, setPendingInviteCode } from "@/lib/auth";
+import { consumePendingInviteCode, pendingInviteAuthPath } from "@/lib/session-user-flow";
import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track";
import { useJoinRoom } from "@/hooks/useRooms";
import { getRoomDetail } from "@/lib/api/rooms";
@@ -36,6 +37,7 @@ export default function JoinPage() {
});
}
+ const entrySearch = window.location.search;
let cancelled = false;
void (async () => {
@@ -43,11 +45,13 @@ export default function JoinPage() {
if (cancelled) return;
if (!authenticated) {
- setPendingInviteCode(inviteCode);
- router.replace("/login");
+ setPendingInviteCode(inviteCode, entrySearch);
+ router.replace(pendingInviteAuthPath("/login"));
return;
}
+ // An authenticated re-entry completes the detour (or replaces an older invite).
+ consumePendingInviteCode();
join(inviteCode, {
onSuccess: async (data) => {
if (data.httpStatus === 200) {
diff --git a/src/app/login/agreements/page.tsx b/src/app/login/agreements/page.tsx
index 1f68261e..8aa44c87 100644
--- a/src/app/login/agreements/page.tsx
+++ b/src/app/login/agreements/page.tsx
@@ -10,6 +10,7 @@ import {
GOOGLE_REACCEPTANCE_TOKEN_NOT_FOUND_ERROR_CODE,
GOOGLE_SIGNUP_TOKEN_NOT_FOUND_ERROR_CODE,
} from "@/lib/api/errors";
+import { pendingInviteAuthPath } from "@/lib/session-user-flow";
import { finishGoogleLogin } from "@/lib/google-login-client";
import {
clearGoogleAgreementFlowSession,
@@ -50,7 +51,7 @@ export default function LoginAgreementsPage() {
useEffect(() => {
const storedFlow = readGoogleAgreementFlowSession();
if (!storedFlow) {
- router.replace("/login");
+ router.replace(pendingInviteAuthPath("/login"));
return;
}
@@ -69,7 +70,7 @@ export default function LoginAgreementsPage() {
const returnToLogin = () => {
clearGoogleAgreementFlowSession();
- router.replace("/login");
+ router.replace(pendingInviteAuthPath("/login"));
};
const handleSubmit = async () => {
@@ -118,7 +119,7 @@ export default function LoginAgreementsPage() {
}
const destination = await finishGoogleLogin(queryClient);
- router.replace(destination ?? "/login?error=OAuthCallback");
+ router.replace(destination ?? pendingInviteAuthPath("/login?error=OAuthCallback"));
} catch {
setErrorMessage(
flow.kind === "reaccept"
diff --git a/src/lib/analytics/context.ts b/src/lib/analytics/context.ts
index 881c19d0..8bf67658 100644
--- a/src/lib/analytics/context.ts
+++ b/src/lib/analytics/context.ts
@@ -34,7 +34,7 @@ const dynamicPagePaths: ReadonlyArray = [
[/^\/bookmark\/[^/]+$/, "/bookmark/[folderId]"],
];
-const analyticsCampaignParameterNames = [
+export const analyticsCampaignParameterNames = [
"utm_id",
"utm_source",
"utm_medium",
diff --git a/src/lib/google-login-client.test.ts b/src/lib/google-login-client.test.ts
new file mode 100644
index 00000000..77e015cc
--- /dev/null
+++ b/src/lib/google-login-client.test.ts
@@ -0,0 +1,68 @@
+// @vitest-environment jsdom
+import { QueryClient } from "@tanstack/react-query";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { finishGoogleLogin } from "./google-login-client";
+import { setPendingInviteCode, consumePendingInviteCode } from "./session-user-flow";
+import { saveGoogleAgreementFlowSession } from "./google-oauth";
+import { analyticsConsentStore } from "./analytics/consent-store";
+import { initializeGoogleAnalytics, revokeGoogleAnalyticsConsent } from "./analytics/client";
+import { tearDownClientSession } from "./client-storage";
+
+vi.mock("./analytics/runtime", () => ({ analyticsRuntime: { enabled: true } }));
+vi.mock("./analytics/amplitude-runtime", () => ({ amplitudeRuntime: { enabled: false } }));
+const session = vi.hoisted(() => ({ read: vi.fn() }));
+vi.mock("./session-user", () => ({ fetchSessionUserWithRetry: session.read }));
+let client: QueryClient;
+function commands() {
+ return (window.dataLayer ?? []).map(c => Array.from(c as ArrayLike));
+}
+beforeEach(() => {
+ client = new QueryClient();
+ sessionStorage.clear();
+ window.history.replaceState(null, "", "/auth/callback?code=test-code&state=test-state");
+ window.dataLayer = [];
+ session.read.mockResolvedValue({ id: 42, email: "fixture@example.test", nickname: "fixture", profileImageUrl: null, provider: "GOOGLE", tutorialCompleted: false });
+ analyticsConsentStore.set("granted");
+ initializeGoogleAnalytics("G-TEST", false);
+ window.dataLayer = [];
+});
+afterEach(() => { client.clear(); vi.clearAllMocks(); });
+it.each(["login", "signup", "reaccept"] as const)("returns the same invite campaign after %s and sets identity before its event", async kind => {
+ setPendingInviteCode("test-invite", "?utm_campaign=existing&code=secret");
+ if (kind === "signup") saveGoogleAgreementFlowSession({ kind, signupToken: "test-signup", expiresAt: Date.now() + 10000 });
+ if (kind === "reaccept") saveGoogleAgreementFlowSession({ kind, reacceptanceToken: "test-reaccept", expiresAt: Date.now() + 10000 });
+ expect(await finishGoogleLogin(client)).toBe("/join/test-invite?utm_campaign=existing");
+ expect(consumePendingInviteCode()).toBeNull();
+ expect(commands()).toEqual([
+ ["set", { user_id: "42" }],
+ ["event", kind === "signup" ? "sign_up" : "login", {
+ method: "google", entry_point: "invite", page_path: "/auth/callback", page_location: "http://localhost:3000/auth/callback",
+ }],
+ ]);
+ tearDownClientSession({ queryClient: client });
+ expect(commands().at(-1)).toEqual(["set", { user_id: null }]);
+});
+it("does not replay a denied-consent login after later consent", async () => {
+ revokeGoogleAnalyticsConsent();
+ analyticsConsentStore.set("denied");
+ window.dataLayer = [];
+ setPendingInviteCode("test-invite", "?utm_source=partner");
+ expect(await finishGoogleLogin(client)).toBe("/join/test-invite?utm_source=partner");
+ expect(commands()).toEqual([]);
+ analyticsConsentStore.set("granted");
+ initializeGoogleAnalytics("G-TEST", false);
+ expect(commands().some(([name]) => name === "event")).toBe(false);
+});
+it("keeps the pending invitation when session confirmation fails", async () => {
+ session.read.mockResolvedValue(null);
+ setPendingInviteCode("test-invite", "?utm_source=partner");
+ expect(await finishGoogleLogin(client)).toBeNull();
+ expect(consumePendingInviteCode()).toBe("test-invite");
+ expect(commands().some(([name]) => name === "event")).toBe(false);
+});
+it("does not invent an invite campaign for ordinary login", async () => {
+ expect(await finishGoogleLogin(client)).toBe("/home");
+ expect(commands()[1]).toEqual(["event", "login", {
+ method: "google", entry_point: "direct", page_path: "/auth/callback", page_location: "http://localhost:3000/auth/callback",
+ }]);
+});
diff --git a/src/lib/google-login-client.ts b/src/lib/google-login-client.ts
index 1e004272..aa949e1a 100644
--- a/src/lib/google-login-client.ts
+++ b/src/lib/google-login-client.ts
@@ -15,6 +15,7 @@ import {
clearGoogleAgreementFlowSession,
readGoogleAgreementFlowSession,
} from "@/lib/google-oauth";
+import { pendingInviteReturnPath } from "@/lib/session-user-flow";
import { setSessionUserCache } from "@/lib/session-user-cache";
import { useSessionStore } from "@/stores/session-store";
@@ -32,6 +33,7 @@ export async function finishGoogleLogin(
setSessionUserCache(queryClient, me);
useSessionStore.getState().setSessionReady(true);
const agreementFlow = readGoogleAgreementFlowSession();
+ const inviteDestination = pendingInviteReturnPath();
const pendingInviteCode = consumePendingInviteCode();
clearGoogleAgreementFlowSession();
setAnalyticsUserId(me.id);
@@ -45,5 +47,5 @@ export async function finishGoogleLogin(
},
);
- return pendingInviteCode ? `/join/${pendingInviteCode}` : "/home";
+ return inviteDestination ?? "/home";
}
diff --git a/src/lib/invite-campaign.test.ts b/src/lib/invite-campaign.test.ts
new file mode 100644
index 00000000..8283bbe6
--- /dev/null
+++ b/src/lib/invite-campaign.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { buildSharedInviteUrl, inviteCampaignSearch, withInviteCampaign } from "./invite-campaign";
+
+describe("invite campaign URLs", () => {
+ it("tags an outgoing invitation with the approved campaign", () => {
+ expect(buildSharedInviteUrl("https://example.test/join/test-invite"))
+ .toBe("https://example.test/join/test-invite?utm_source=member_invite&utm_medium=referral&utm_campaign=invite");
+ });
+ it.each([
+ "utm_source=partner&utm_medium=email&utm_campaign=launch&utm_content=a",
+ "utm_campaign=launch", "utm_source=", "utm_custom=keep",
+ ])("does not mix defaults into an existing campaign: %s", search => {
+ const url = `https://example.test/join/test-invite?${search}#section`;
+ expect(buildSharedInviteUrl(url)).toBe(url);
+ });
+ it("carries only allowed campaign keys, including empty and repeated values", () => {
+ expect(inviteCampaignSearch("?utm_source=partner&utm_content=a&utm_content=b&utm_campaign=&code=secret&state=secret&inviteCode=secret&redirect=https://other.test#hash"))
+ .toBe("utm_source=partner&utm_content=a&utm_content=b&utm_campaign=");
+ });
+ it("preserves destination campaign as one set, rather than merging fields", () => {
+ expect(withInviteCampaign("/login?utm_campaign=other&error=OAuthCallback", "utm_source=member_invite"))
+ .toBe("/login?utm_campaign=other&error=OAuthCallback");
+ });
+ it("carries context without inventing a default on internal navigation", () => {
+ expect(withInviteCampaign("/login?error=OAuthCallback", "utm_source=partner"))
+ .toBe("/login?error=OAuthCallback&utm_source=partner");
+ expect(withInviteCampaign("/login", "")).toBe("/login");
+ });
+});
diff --git a/src/lib/invite-campaign.ts b/src/lib/invite-campaign.ts
new file mode 100644
index 00000000..0f16ed4d
--- /dev/null
+++ b/src/lib/invite-campaign.ts
@@ -0,0 +1,37 @@
+import { analyticsCampaignParameterNames } from "@/lib/analytics/context";
+
+const allowedCampaignNames = new Set(analyticsCampaignParameterNames);
+const defaultInviteCampaign = "utm_source=member_invite&utm_medium=referral&utm_campaign=invite";
+
+function hasCampaign(search: URLSearchParams): boolean {
+ return [...search.keys()].some(name => name.startsWith("utm_"));
+}
+
+/** Navigation context only: never retain OAuth parameters, arbitrary queries or fragments. */
+export function inviteCampaignSearch(search: string): string {
+ const source = new URLSearchParams(search.split("#", 1)[0]);
+ const campaign = new URLSearchParams();
+ for (const [name, value] of source) {
+ if (allowedCampaignNames.has(name)) campaign.append(name, value);
+ }
+ return campaign.toString();
+}
+
+/** Preserve an existing campaign as a whole, including partial/empty sets. */
+export function withInviteCampaign(path: string, campaign: string): string {
+ const hashIndex = path.indexOf("#");
+ const beforeHash = hashIndex === -1 ? path : path.slice(0, hashIndex);
+ const fragment = hashIndex === -1 ? "" : path.slice(hashIndex);
+ const queryIndex = beforeHash.indexOf("?");
+ const search = new URLSearchParams(queryIndex === -1 ? "" : beforeHash.slice(queryIndex + 1));
+ if (hasCampaign(search)) return path;
+ const allowedCampaign = inviteCampaignSearch(campaign);
+ if (!allowedCampaign) return path;
+ const separator = queryIndex === -1 ? "?" : beforeHash.endsWith("?") ? "" : "&";
+ return `${beforeHash}${separator}${allowedCampaign}${fragment}`;
+}
+
+/** Only outgoing copy/share links create the default invitation campaign. */
+export function buildSharedInviteUrl(url: string): string {
+ return withInviteCampaign(url, defaultInviteCampaign);
+}
diff --git a/src/lib/session-user-flow.test.ts b/src/lib/session-user-flow.test.ts
new file mode 100644
index 00000000..524fc2ff
--- /dev/null
+++ b/src/lib/session-user-flow.test.ts
@@ -0,0 +1,26 @@
+// @vitest-environment jsdom
+import { beforeEach, expect, it } from "vitest";
+import { consumePendingInviteCode, pendingInviteAuthPath, setPendingInviteCode } from "./session-user-flow";
+
+beforeEach(() => sessionStorage.clear());
+it("preserves an invite campaign across login and agreements until consumed", () => {
+ setPendingInviteCode("test-invite", "?utm_source=partner&utm_campaign=launch&code=secret");
+ expect(pendingInviteAuthPath("/login")).toBe("/login?utm_source=partner&utm_campaign=launch");
+ expect(pendingInviteAuthPath("/login/agreements")).toBe("/login/agreements?utm_source=partner&utm_campaign=launch");
+ expect(consumePendingInviteCode()).toBe("test-invite");
+ expect(pendingInviteAuthPath("/login")).toBe("/login");
+ expect(consumePendingInviteCode()).toBeNull();
+});
+it("replaces the entire old campaign when a different invitation arrives", () => {
+ setPendingInviteCode("first", "?utm_source=partner&utm_medium=email");
+ setPendingInviteCode("second", "?utm_campaign=partial");
+ expect(pendingInviteAuthPath("/login")).toBe("/login?utm_campaign=partial");
+ setPendingInviteCode("third");
+ expect(pendingInviteAuthPath("/login")).toBe("/login");
+ expect(consumePendingInviteCode()).toBe("third");
+});
+it("does not add a default campaign to a legacy or general login", () => {
+ expect(pendingInviteAuthPath("/login?error=OAuthCallback")).toBe("/login?error=OAuthCallback");
+ sessionStorage.setItem("pendingInviteCode", "legacy");
+ expect(pendingInviteAuthPath("/login")).toBe("/login");
+});
diff --git a/src/lib/session-user-flow.ts b/src/lib/session-user-flow.ts
index 6ba236b2..a51ca773 100644
--- a/src/lib/session-user-flow.ts
+++ b/src/lib/session-user-flow.ts
@@ -1,3 +1,5 @@
+import { inviteCampaignSearch, withInviteCampaign } from "@/lib/invite-campaign";
+
/** OAuth·초대 등 인증 흐름 부가 유틸 */
export async function checkClientAuthenticated(): Promise {
@@ -10,16 +12,37 @@ export async function checkClientAuthenticated(): Promise {
}
const PENDING_INVITE_KEY = "pendingInviteCode";
+const PENDING_INVITE_CAMPAIGN_KEY = "pendingInviteCampaign";
-export function setPendingInviteCode(code: string): void {
+export function setPendingInviteCode(code: string, search = ""): void {
if (typeof window === "undefined") return;
+ // Replace, never merge: a new invite must not inherit the previous campaign.
+ sessionStorage.removeItem(PENDING_INVITE_CAMPAIGN_KEY);
sessionStorage.setItem(PENDING_INVITE_KEY, code);
+ const campaign = inviteCampaignSearch(search);
+ if (campaign) sessionStorage.setItem(PENDING_INVITE_CAMPAIGN_KEY, campaign);
}
export function consumePendingInviteCode(): string | null {
if (typeof window === "undefined") return null;
const value = sessionStorage.getItem(PENDING_INVITE_KEY);
- if (value) sessionStorage.removeItem(PENDING_INVITE_KEY);
+ sessionStorage.removeItem(PENDING_INVITE_KEY);
+ sessionStorage.removeItem(PENDING_INVITE_CAMPAIGN_KEY);
return value;
}
+function pendingInviteCampaign(): string {
+ if (typeof window === "undefined" || !sessionStorage.getItem(PENDING_INVITE_KEY)) return "";
+ return sessionStorage.getItem(PENDING_INVITE_CAMPAIGN_KEY) ?? "";
+}
+
+/** Only the invite authentication detour carries this context; ordinary login creates none. */
+export function pendingInviteAuthPath(path: "/login" | "/login/agreements" | `/login?error=${string}`): string {
+ return withInviteCampaign(path, pendingInviteCampaign());
+}
+
+export function pendingInviteReturnPath(): string | null {
+ if (typeof window === "undefined") return null;
+ const code = sessionStorage.getItem(PENDING_INVITE_KEY);
+ return code ? withInviteCampaign(`/join/${encodeURIComponent(code)}`, pendingInviteCampaign()) : null;
+}