diff --git a/docs/analytics/amplitude-operations.md b/docs/analytics/amplitude-operations.md
index 60e67aa2..45fce9b5 100644
--- a/docs/analytics/amplitude-operations.md
+++ b/docs/analytics/amplitude-operations.md
@@ -79,7 +79,7 @@ AdGuard 같은 DNS 광고 차단기를 사용하면 아래 호스트를 허용
- 생성자 활성화 퍼널: `create_plan` → `add_to_itinerary` (신규 가입자는 별도 코호트로 구분)
- 참여자 활성화 퍼널: `invite_view` → `join_group` → `view_plan`; 이후 일정 편집과 조회 위주 사용을 구분
-- 공유: 생성자의 `create_plan → share`와 참여자의 `invite_view → join_group`을 분리. 서로 다른 사람의 행동을 기본 사용자 퍼널로 연결하지 않는다. 여행방 기능 이벤트에는 기존 방 ID를 이벤트별 `groups.room_id`로 전달한다. 그룹 보고 기능의 사용 가능 여부를 확인한 뒤 방 기준 퍼널로 분석한다(정확한 신규 합류 판정은 별도 백엔드 이벤트 필요).
+- 공유: 공유자(HOST 또는 MEMBER)의 `share`와 참여자의 `invite_view → join_group`을 분리. 생성자의 `create_plan → share`는 별도 세그먼트로 본다. 서로 다른 사람의 행동을 기본 사용자 퍼널로 연결하지 않는다. 여행방 기능 이벤트에는 기존 방 ID를 이벤트별 `groups.room_id`로 전달한다. 그룹 보고 기능의 사용 가능 여부를 확인한 뒤 방 기준 퍼널로 분석한다(정확한 신규 합류 판정은 별도 백엔드 이벤트 필요).
- 탐색 퍼널: `view_search_results` → `view_place` → `add_to_bookmark` 또는 `add_to_itinerary`
- 온보딩 퍼널: `tutorial_begin` → `tutorial_complete`; `tutorial_skip`은 `skip_step`별 이탈을 분석한다.
@@ -97,7 +97,7 @@ AdGuard 같은 DNS 광고 차단기를 사용하면 아래 호스트를 허용
- 재사용 분석은 단순 페이지 재방문과 핵심 행동 재사용을 구분하고 여행 준비 기간/다음 여행을 고려한다.
- 운영 Tracking Plan·대시보드·GA4 주요 이벤트 지정은 원격 설정이며 코드 변경만으로 완료되지 않는다. 가계부·준비물 이벤트에는 공통 정규화 URL과 방 ID만 사용하고 금액·메모·이름·항목 식별자는 추가하지 않는다.
-방 단위 퍼널은 그룹 타입 `room_id`를 선택하고 `share` 단계에 `role=host`, `join_group` 단계에 `role=member`를 적용한다. Accounts 기능 활성화/계약은 이번 프론트 코드 변경과 별개다. 이벤트 속성에 방 ID가 보이는 것만으로 그룹 퍼널이 설정된 것은 아니다.
+방 단위 퍼널은 그룹 타입 `room_id`를 선택하고 `share` 단계는 HOST와 MEMBER 전체를 포함하고, 역할별 비교가 필요하면 `role=host`와 `role=member` 세그먼트로 나눈다. `join_group` 단계에는 `role=member`를 적용한다. Accounts 기능 활성화/계약은 이번 프론트 코드 변경과 별개다. 이벤트 속성에 방 ID가 보이는 것만으로 그룹 퍼널이 설정된 것은 아니다.
그룹 분석은 생성·조회·일정·북마크·채팅·가계부·준비물 전반에 사용할 수 있다. 검색·장소 상세·튜토리얼·페이지 조회는 확정된 방 문맥에서만 그룹을 가진다. 준비물은 개인 기능이며 이를 공동 편집 활성의 근거로 삼지 않는다. 협업 지표에는 적절한 이벤트 집합과 서로 다른 참여자 조건을 별도로 정의한다.
@@ -110,3 +110,8 @@ AdGuard 같은 DNS 광고 차단기를 사용하면 아래 호스트를 허용
- SDK 초기화 전 대기 중이던 이전 사용자의 Amplitude 명령은 폐기한다. 초기화 완료 후 식별자를 재설정한 다음 로그아웃 이후 명령을 처리한다. 초기화 중에는 SDK 자동 수집을 일시 정지하고, 동의가 유지된 경우에만 준비 후 재개한다.
- SDK가 시작되지 않았다면 해당 프로젝트의 저장된 식별 쿠키만 제거한다. 로그아웃 때문에 SDK를 초기화하거나 동의를 허용하지 않는다. 초기화 실패 후 재시도에도 식별 경계를 유지한다.
- 이 처리는 로그아웃 이후 이벤트의 귀속을 분리하며, 이미 수집된 과거 이벤트를 삭제하는 기능은 아니다.
+
+
+## 멤버 권한 관련 코드 계측
+
+`invite_code_issued`(명시적 발급·재발급 성공)와 `room_info_updated`(방 정보 저장 성공)는 Amplitude 전용이다. 자동 초대 코드 fallback, 실패·취소, 후속 재조회는 집계하지 않는다. 두 이벤트의 `room_id`는 요청 대상 방이고 `role`은 `host`/`member`이며, 코드·링크·제목 등 원문을 속성에 넣지 않는다. 전체 공유 분석은 기존 `share`의 두 역할 모두를 포함한다. 이 문서는 소스 계약이며 배포나 실제 수신을 확인했다는 의미가 아니다.
diff --git a/docs/analytics/amplitude-tracking-plan.md b/docs/analytics/amplitude-tracking-plan.md
index 8a510cbd..00529350 100644
--- a/docs/analytics/amplitude-tracking-plan.md
+++ b/docs/analytics/amplitude-tracking-plan.md
@@ -38,6 +38,8 @@
| `reorder_itinerary` | 드래그 앤 드롭 일정 재정렬이 반영됐을 때 | `item_count_bucket`, `method=drag_drop`, `room_id` (Amplitude만) | 없음 | Amplitude |
| `view_search_results` | 새로운 검색 결과 세대가 화면에 반영됐을 때 | `result_count_bucket`, `search_mode` | `room_id` (확정된 방만, Amplitude) | Amplitude |
| `share` | 초대 링크 복사 또는 네이티브 공유가 성공했을 때 | `method`, `room_id` (Amplitude만) | `member_count_bucket`, `role` | 공통 |
+| `invite_code_issued` | 본인의 명시적 초대 코드 발급·재발급 POST 성공 | `room_id`, `role` | 없음 | Amplitude |
+| `room_info_updated` | 본인의 방 정보 PATCH 성공 | `room_id`, `role` | 없음 | Amplitude |
| `chat_message_sent` | 텍스트·AI·장소 채팅 전송 액션이 실행됐을 때 | `message_type`, `room_id` (Amplitude만) | 없음 | Amplitude |
| `tutorial_begin` | 사이드바 튜토리얼이 처음 시작됐을 때 | `tutorial_version=sidebar_v1` | `room_id` (확정된 방만, Amplitude) | Amplitude |
| `tutorial_complete` | 사이드바 튜토리얼의 마지막 단계를 완료했을 때 | `tutorial_version=sidebar_v1` | `room_id` (확정된 방만, Amplitude) | Amplitude |
@@ -102,9 +104,9 @@
## 후속 후보와 분석 단위
-이번 구현은 위 이벤트로 제한한다. 가계부 필터, 준비물 이름·메모·삭제·카테고리 관리, 일정 시간·날짜·이동수단 변경, 일차 관리, 북마크 이동/삭제, 여행·멤버 관리의 세부 이벤트는 아직 추가하지 않았다. 분석 질문이 생기면 Amplitude 전용 후보로 검토한다. 원문 속성은 추가하지 않는다.
+이번 구현은 위 이벤트로 제한한다. 가계부 필터, 준비물 이름·메모·삭제·카테고리 관리, 일정 시간·날짜·이동수단 변경, 일차 관리, 북마크 이동/삭제, 이번 명시적 초대 코드 발급·재발급과 방 정보 저장을 제외한 여행·멤버 관리의 세부 이벤트는 아직 추가하지 않았다. 분석 질문이 생기면 Amplitude 전용 후보로 검토한다. 원문 속성은 추가하지 않는다.
-개인 활성화는 생성자 `create_plan → add_to_itinerary`, 초대 참여자 `invite_view → join_group → view_plan`처럼 분리한다. 방장의 `share`와 다른 사람의 `join_group`을 기본 사용자 퍼널로 연결하지 않는다. 여행방에 속한 기능 이벤트에 이벤트별 여행방 그룹 계측을 적용했다. Amplitude 그룹 보고 기능의 계약/활성화와 퍼널 구성은 별도로 필요하다. 준비물은 여행방 공동 작업이 아닌 개인 사용이다.
+개인 활성화는 생성자 `create_plan → add_to_itinerary`, 초대 참여자 `invite_view → join_group → view_plan`처럼 분리한다. 공유자(HOST 또는 MEMBER)의 `share`와 다른 사람의 `join_group`을 기본 사용자 퍼널로 연결하지 않는다. 여행방에 속한 기능 이벤트에 이벤트별 여행방 그룹 계측을 적용했다. Amplitude 그룹 보고 기능의 계약/활성화와 퍼널 구성은 별도로 필요하다. 준비물은 여행방 공동 작업이 아닌 개인 사용이다.
## 여행방 단위 그룹 분석
@@ -113,7 +115,7 @@
- `join_group`: 즉시 참여와 승인 대기 완료 모두 서버 응답의 `id`를 사용한다. 추가 방 정보 조회가 실패해도 응답의 방 ID는 유지한다.
- `invite_view`: 로그인 전에는 방 ID를 알 수 없어 기존 상태를 유지한다. 초대 코드를 ID로 대체하지 않는다.
- GA4로 보내거나 GA 대기열에 넣기 전에 `room_id`를 제거한다. 양 도구의 사용자 ID는 계속 실제 사용자 ID다. URL 정규화 정책도 유지한다.
-- Amplitude Accounts 그룹 분석 사용 가능 여부를 확인하고 그룹 타입 `room_id`로 퍼널을 만든다. 1단계는 `share` + `role=host`, 2단계는 `join_group` + `role=member`다. 관찰 기간(예: 7일)은 보고서에 명시한다.
+- Amplitude Accounts 그룹 분석 사용 가능 여부를 확인하고 그룹 타입 `room_id`로 퍼널을 만든다. 전체 공유 퍼널의 1단계는 `share`이며 HOST와 MEMBER를 모두 포함한다. 역할별 비교는 `role=host`와 `role=member` 세그먼트로 나누고, 2단계는 `join_group` + `role=member`로 본다. 관찰 기간(예: 7일)은 보고서에 명시한다.
- 이 퍼널은 같은 방에서 공유 후 참여 완료가 관측됐다는 뜻이다. 특정 링크/공유자가 실제 합류를 유발했다는 증명은 아니며 `share`의 복사 성공도 전달 완료가 아니다.
- 현재 참여 API 응답에는 신규 멤버십 생성 여부/고유 참여 발생 ID가 없다. 클라이언트의 `join_group`만으로 기존 멤버 재진입과 신규 합류를 엄밀히 구분하거나 서버 기준으로 중복 제거할 수 없다. 정확한 신규 참여 전환율에는 백엔드의 멤버십 확정 이벤트가 추가로 필요하다.
@@ -127,3 +129,12 @@
- 이벤트 수집 목적지는 그대로다. 그룹 확대는 Amplitude의 방 단위 분석 문맥만 추가하며 GA4로 방 ID를 전달하지 않는다.
페이지 조회는 늦은 사용자/방 문맥 보충만으로 중복하지 않는다. 사용자 조회 오류/익명 상태의 기존 페이지뷰는 그룹 없이 유지하고, 실제로 화면의 대상 방이 바뀐 경우를 별도 조회로 센다. `/packing/{roomId}`도 다른 동적 경로와 같이 정규화하여 GA URL에 원본 방 ID가 남지 않게 한다.
+
+
+## 멤버 초대 발급과 방 정보 수정
+
+- 기존 초대 코드 발급·재발급 UI는 활성 HOST와 MEMBER가 사용한다. 조회·공유는 기존 코드를 유지하며, 명시적 POST 성공은 새 코드 발급/기존 코드 교체를 뜻한다. `invite_code_issued`는 두 경우를 포괄하며 최초 발급인지 재발급인지 추측하지 않는다. 코드 누락 시 자동 fallback은 이 이벤트로 세지 않는다.
+- `room_info_updated`는 서버 PATCH 성공 직후, 캐시 갱신·후속 일정 재조회 전에 한 번 기록한다. 요청 실패·취소·연속 클릭으로 차단된 요청·재조회는 성공 이벤트가 아니다.
+- 두 이벤트는 Amplitude 전용이며 기존 `room_id`와 이벤트별 `groups.room_id`를 사용한다. GA4로 보내지 않는다. `role`은 `host` 또는 `member`: 방 정보 수정은 서버 성공 응답의 실제 역할, 발급은 요청 시작 시 서버 조회 캐시에 있는 역할을 사용한다. 발급 응답에는 역할이 없으므로 서버 처리 순간의 역할을 재검증했다고 해석하지 않는다.
+- 요청 시작과 성공 시 모두 분석 동의·로그인 문맥이 유효해야 한다. 대기 중 계정/선택 방 변경, 세션 종료, 알려진 멤버십 상실, 관련 캐시 제거, 동의 철회가 발생하면 원래 문맥으로 돌아와도 해당 요청의 이벤트를 보내지 않는다. 홈 카드처럼 다른 방을 대상으로 한 행동도 요청 인자의 방 ID를 사용한다.
+- 초대 코드/링크, 여행 제목·여행지·날짜 원문은 보내지 않는다. 새 초대자 링크 조회 이벤트는 추가하지 않는다. 기존 `share`는 HOST와 MEMBER 모두 복사/공유 성공 후 기록하며 기존 의미를 유지한다.
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 7b5adf67..109a470d 100644
--- a/src/app/(main)/member-settings/_components/AddMemberPanel.analytics.test.tsx
+++ b/src/app/(main)/member-settings/_components/AddMemberPanel.analytics.test.tsx
@@ -12,20 +12,23 @@ beforeEach(() => {
vi.stubGlobal("window", { location: { origin: "https://example.test", pathname: "/member-settings" } });
});
afterEach(async () => { await act(async () => renderer?.unmount()); vi.unstubAllGlobals(); vi.clearAllMocks(); });
-async function mount() {
- await act(async () => { renderer = create(); });
+async function mount(role: "HOST" | "MEMBER" = "HOST") {
+ await act(async () => { renderer = create(); });
}
-it.each(["copy_link", "native_share"])("records the existing room only after %s succeeds", async (method) => {
+it.each([
+ ["copy_link", "HOST"], ["native_share", "HOST"],
+ ["copy_link", "MEMBER"], ["native_share", "MEMBER"],
+] as const)("records the existing room only after %s succeeds for %s", async (method, role) => {
let resolve!: () => void;
const pending = new Promise(done => { resolve = done; });
vi.stubGlobal("navigator", { clipboard: { writeText: () => pending }, share: () => pending });
- await mount();
+ await mount(role);
const button = renderer.root.findAllByType("button").find(b => b.children.includes(method === "copy_link" ? "복사" : "공유"))!;
await act(async () => { void button.props.onClick(); });
expect(commands).not.toHaveBeenCalled();
await act(async () => { resolve(); await pending; });
expect(commands).toHaveBeenCalledExactlyOnceWith("event", "share", {
- room_id: "room-42", role: "host", member_count_bucket: "2", method,
+ room_id: "room-42", role: role.toLowerCase(), member_count_bucket: "2", method,
page_path: "/member-settings", page_location: "https://example.test/member-settings",
});
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 0b25c19c..04993a86 100644
--- a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx
+++ b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx
@@ -7,7 +7,7 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useRegenerateInviteCode } from "@/hooks/useRooms";
-import { isHostRole, isRoomMemberRole } from "@/lib/rooms";
+import { isRoomMemberRole } from "@/lib/rooms";
import {
bucketMemberCount,
toAnalyticsRoomRole,
@@ -19,8 +19,6 @@ type Props = {
inviteCode: string | null | undefined;
memberCount?: number;
role?: string;
- /** 방장 여부를 이미 계산한 화면은 넘긴다 — 없으면 `role`로 판단 */
- isHost?: boolean;
isRoomDetailLoading: boolean;
isRoomDetailError: boolean;
/** 안내 문구 — 모달처럼 바깥에서 본문으로 보여줄 때는 false */
@@ -52,15 +50,14 @@ export function AddMemberPanel({
inviteCode,
memberCount,
role,
- isHost,
isRoomDetailLoading,
isRoomDetailError,
showDescription = true,
}: Readonly) {
const roomIdTrim = roomId.trim();
- // 초대 코드 발급·재발급 API는 방장 전용 — 참여자는 서버가 내려준 코드가 있을 때만 링크를 보여준다
const canShareInvite = isRoomMemberRole(role) && !isRoomDetailError;
- const canIssue = canShareInvite && (isHost ?? isHostRole(role));
+ const canIssue = canShareInvite && !isRoomDetailLoading;
+ const issueInFlight = useRef(false);
const detailInviteCode = normalizeInviteCode(inviteCode);
const [copied, setCopied] = useState(false);
const copyTimerRef = useRef | null>(null);
@@ -91,11 +88,14 @@ export function AddMemberPanel({
autoIssueAttemptedRoomRef.current = roomIdTrim;
- regenerate(roomIdTrim, {
+ issueInFlight.current = true;
+ regenerate({ roomId: roomIdTrim, trigger: "automatic" }, {
onSuccess: ({ inviteCode: newCode }) => {
+ issueInFlight.current = false;
setIssuedCode(newCode);
},
onError: () => {
+ issueInFlight.current = false;
toast.error("초대 링크를 발급하지 못했어요.");
},
});
@@ -166,13 +166,16 @@ export function AddMemberPanel({
}
function handleRegenerate() {
- if (!roomIdTrim.length || !canIssue || isRegenerating) return;
+ if (!roomIdTrim.length || !canIssue || isRegenerating || issueInFlight.current) return;
setCopied(false);
- regenerate(roomIdTrim, {
+ issueInFlight.current = true;
+ regenerate({ roomId: roomIdTrim, trigger: "explicit" }, {
onSuccess: ({ inviteCode: newCode }) => {
+ issueInFlight.current = false;
setIssuedCode(newCode);
},
onError: () => {
+ issueInFlight.current = false;
toast.error("초대 링크를 재발급하지 못했어요.");
},
});
diff --git a/src/app/(main)/member-settings/_components/RoomMembersSection.tsx b/src/app/(main)/member-settings/_components/RoomMembersSection.tsx
index e9fd3da4..36c2bac3 100644
--- a/src/app/(main)/member-settings/_components/RoomMembersSection.tsx
+++ b/src/app/(main)/member-settings/_components/RoomMembersSection.tsx
@@ -37,7 +37,6 @@ export function RoomMembersSection() {
inviteCode={roomDetail?.inviteCode}
memberCount={roomDetail?.memberCount}
role={roomDetail?.role}
- isHost={isHost}
isRoomDetailLoading={isDetailLoading}
isRoomDetailError={isDetailError}
/>
diff --git a/src/app/home/_components/RoomCardMenu.test.tsx b/src/app/home/_components/RoomCardMenu.test.tsx
index 3232de7e..ade0e36e 100644
--- a/src/app/home/_components/RoomCardMenu.test.tsx
+++ b/src/app/home/_components/RoomCardMenu.test.tsx
@@ -80,21 +80,19 @@ it("issues a link once when the room detail confirms no existing code", async ()
state.detail!.inviteCode = null;
await openInvite();
expect(state.regenerate).toHaveBeenCalledTimes(1);
- expect(state.regenerate.mock.calls[0][0]).toBe("selected-room");
+ 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`);
});
-it.each(["loading", "error", "member"])("does not issue links while detail is %s", async (mode) => {
+it.each(["loading", "error"])("does not issue links while detail is %s", async (mode) => {
state.detail!.inviteCode = null;
state.loading = mode === "loading";
state.error = mode === "error";
- if (mode === "member") state.detail!.role = "MEMBER";
await openInvite();
expect(state.regenerate).not.toHaveBeenCalled();
- if (mode === "member") expect(button("복사").disabled).toBe(true);
- else expect(button("복사")).toBeUndefined();
+ expect(button("복사")).toBeUndefined();
if (mode === "error") {
await click("다시 시도");
expect(state.refetch).toHaveBeenCalledOnce();
@@ -158,7 +156,7 @@ it("lets a member copy and share the existing link without issuing or regenerati
expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/existing-code`);
await click("공유");
expect(state.copy).toHaveBeenCalledTimes(2);
- expect(button("초대 링크 재발급")).toBeUndefined();
+ expect(button("초대 링크 재발급")).toBeDefined();
expect(state.regenerate).not.toHaveBeenCalled();
});
@@ -169,10 +167,24 @@ it("does not expose an invite from a nonmember detail response", async () => {
expect(state.regenerate).not.toHaveBeenCalled();
});
-it.each(["PENDING", undefined])("hides even a cached invite link for invalid role %s", async (role) => {
+it.each(["PENDING", "LEFT", undefined])("hides even a cached invite link for invalid role %s", async (role) => {
await act(async () => root.render());
expect(document.querySelector('input[aria-label="초대 링크"]')).toBeNull();
expect(button("복사").disabled).toBe(true);
expect(button("초대 링크 재발급")).toBeUndefined();
expect(state.regenerate).not.toHaveBeenCalled();
});
+
+it("lets MEMBER use the existing reissue button once for rapid clicks", async () => {
+ state.detail!.role = "MEMBER";
+ await render("MEMBER"); await click("제주 여행 더보기"); await click("초대하기");
+ await act(async () => { button("초대 링크 재발급").click(); button("초대 링크 재발급").click(); });
+ expect(state.regenerate).toHaveBeenCalledTimes(1);
+ expect(state.regenerate.mock.calls[0][0]).toEqual({ roomId: "selected-room", trigger: "explicit" });
+});
+it("allows MEMBER automatic fallback without presenting it as an explicit issuance", async () => {
+ state.detail!.role = "MEMBER"; state.detail!.inviteCode = null;
+ await render("MEMBER"); await click("제주 여행 더보기"); await click("초대하기");
+ expect(state.regenerate).toHaveBeenCalledTimes(1);
+ expect(state.regenerate.mock.calls[0][0]).toEqual({ roomId: "selected-room", trigger: "automatic" });
+});
diff --git a/src/components/layout/HeaderMemberPreview.tsx b/src/components/layout/HeaderMemberPreview.tsx
index ae95d28e..6ee2033d 100644
--- a/src/components/layout/HeaderMemberPreview.tsx
+++ b/src/components/layout/HeaderMemberPreview.tsx
@@ -174,7 +174,6 @@ export function HeaderMemberPreview() {
inviteCode={roomDetail?.inviteCode}
memberCount={roomDetail?.memberCount}
role={roomDetail?.role}
- isHost={isHost}
isRoomDetailLoading={isDetailLoading}
isRoomDetailError={isDetailError}
showDescription={false}
diff --git a/src/components/rooms/RoomTripEditForm.test.tsx b/src/components/rooms/RoomTripEditForm.test.tsx
index 527791f2..55e427d7 100644
--- a/src/components/rooms/RoomTripEditForm.test.tsx
+++ b/src/components/rooms/RoomTripEditForm.test.tsx
@@ -69,3 +69,21 @@ it("preserves drafts and original date floor on same-room refresh; cancel and ro
expect(state.update).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
+
+it("does not submit on cancel and submits once for rapid apply clicks", async () => {
+ state.update.mockClear();
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ const room = { id: "r", title: "old", destinations: ["서울"], startDate: "2027-01-01", endDate: "2027-01-03" };
+ await act(async () => root.render());
+ await act(async () => state.fields!.onTitleChange("cancelled"));
+ await act(async () => container.querySelectorAll("button")[0].click());
+ expect(state.update).not.toHaveBeenCalled();
+ await act(async () => state.fields!.onTitleChange("new"));
+ await act(async () => {
+ container.querySelectorAll("button")[1].click();
+ container.querySelectorAll("button")[1].click();
+ });
+ expect(state.update).toHaveBeenCalledTimes(1);
+ await act(async () => root.unmount());
+});
diff --git a/src/components/rooms/RoomTripEditForm.tsx b/src/components/rooms/RoomTripEditForm.tsx
index 68c138a8..2d82f4c5 100644
--- a/src/components/rooms/RoomTripEditForm.tsx
+++ b/src/components/rooms/RoomTripEditForm.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useState } from "react";
+import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import {
@@ -85,7 +85,10 @@ function RoomTripEditSession({ room, readOnly = false, onCancel, onSaved }: Prop
!isPending &&
isDirty;
+ const submitInFlight = useRef(false);
const submitUpdate = useCallback(() => {
+ if (submitInFlight.current || readOnly) return;
+ submitInFlight.current = true;
updateRoom(
{
roomId: room.id,
@@ -99,6 +102,7 @@ function RoomTripEditSession({ room, readOnly = false, onCancel, onSaved }: Prop
previousEndDate: saved.endDate,
},
{
+ onSettled: () => { submitInFlight.current = false; },
onSuccess: () => {
setShrinkConfirmOpen(false);
toast.success("여행 정보가 수정되었어요");
@@ -116,6 +120,7 @@ function RoomTripEditSession({ room, readOnly = false, onCancel, onSaved }: Prop
startDate,
title,
updateRoom,
+ readOnly,
onSaved,
]);
diff --git a/src/hooks/useRooms.analytics.test.tsx b/src/hooks/useRooms.analytics.test.tsx
new file mode 100644
index 00000000..52075034
--- /dev/null
+++ b/src/hooks/useRooms.analytics.test.tsx
@@ -0,0 +1,141 @@
+// @vitest-environment jsdom
+import { useEffect } from "react";
+import { act, create, type ReactTestRenderer } from "react-test-renderer";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { useRegenerateInviteCode, useUpdateRoom } from "./useRooms";
+import { analyticsConsentStore } from "@/lib/analytics/consent-store";
+import { initializeAmplitude } from "@/lib/analytics/amplitude";
+import { initializeGoogleAnalytics } from "@/lib/analytics/client";
+import { roomDetailQueryKey, roomMembersQueryKey, sessionUserQueryKey } from "@/lib/query-keys";
+import { useSessionStore } from "@/stores/session-store";
+
+const calls = vi.hoisted(() => ({
+ issue: vi.fn(), update: vi.fn(), hydrate: vi.fn(),
+ track: vi.fn(), initAll: vi.fn(async () => {}), setUserId: vi.fn(), setOptOut: vi.fn(), reset: vi.fn(),
+}));
+vi.mock("@/lib/api/rooms", async (original) => ({
+ ...await original