Repository: lyra-j/uuno
Pull request #86: [FEAT]#74 명함 인터렉션 히트맵
Author: login: sum529-create
Labels: name: ✨Feature
description: 기능 개발
name: 🌸 수민
description: 담당자 노수민
Created: 2025-04-13T17:12:49Z | Merged: 2025-04-13T18:40:12Z

## #️⃣ 연관된 이슈

- Closed #74

<br>

## 📝 작업 내용

- 날짜 형식 반환 함수 및 세션 관리 유틸함수 추가
- 명함 인터렉션 히트맵(노선, 깃허브, 카톡, 링크드인, 카톡)
- 키보드 / 마우스 30분간 반응이 없을 경우 및 창을 닫을경우 end_at 변경

<br>

## 🖼 스크린샷

https://github.com/user-attachments/assets/5a305f16-ac32-42d8-9fed-5a93284ca3d3



<br>

## 💬 리뷰 요구사항

> 리팩토링까지 진행하면서 파일수가 많아졌습니다..


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **새로운 기능**
	- 카드 ID를 슬러그를 기반으로 검색하는 기능이 추가되었습니다.
	- 비즈니스 카드에 연결된 소셜 링크 목록을 가져오는 기능이 추가되었습니다.
	- 사용자 IP 주소를 가져오는 기능이 도입되었습니다.
	- 사용자 상호작용을 기록하고 세션을 관리하는 기능이 강화되었습니다.
	- 카드 이미지 다운로드 기능이 추가되었습니다.
	- FlipCard 컴포넌트에 소셜 링크 버튼이 추가되어, 관련 사이트로 손쉽게 이동할 수 있습니다.
	- 세션 초기화 및 종료, 상호작용 로깅을 위한 사용자 정의 훅이 추가되었습니다.
	- 사용자 상호작용을 추적하는 기능이 추가되었습니다.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Linked issue #74: [FEAT] 인터렉션 히트맵
# ✨ Feature

### 1. 어떤 기능인가요?

- 인터렉션 분석

<br>

### 2. 작업 상세 내용

- [ ] 공유된 페이지 명함 렌더링
- [ ] 인터렉션 히트맵

<br>

### 3. 참고할만한 자료 (선택)


Diff:
diff --git a/src/apis/card-interaction.ts b/src/apis/card-interaction.ts
new file mode 100644
index 00000000..92ef1b71
--- /dev/null
+++ b/src/apis/card-interaction.ts
@@ -0,0 +1,26 @@
+import { DB_COLUMNS, TABLES } from '@/constants/tables.constant';
+import { createClient } from '@/utils/supabase/client';
+
+/**
+ * 슬러그를 기반으로 명함 ID를 조회
+ *
+ * @param slug 명함 슬러그
+ * @returns 해당 슬러그에 매칭 명함 ID
+ */
+export const getCardId = async (slug: string) => {
+  const supabase = await createClient();
+  const { data, error } = await supabase
+    .from(TABLES.CARDS)
+    .select(DB_COLUMNS.CARDS.ID)
+    .eq(DB_COLUMNS.CARDS.SLUG, slug);
+
+  if (error) {
+    throw error;
+  }
+
+  if (!data || data.length === 0) {
+    throw new Error(`명함을 찾을 수 없습니다: ${slug}`);
+  }
+
+  return data[0].id;
+};
diff --git a/src/apis/card-social-list.ts b/src/apis/card-social-list.ts
new file mode 100644
index 00000000..0186bc5e
--- /dev/null
+++ b/src/apis/card-social-list.ts
@@ -0,0 +1,20 @@
+import { DB_COLUMNS, TABLES } from '@/constants/tables.constant';
+import { createClient } from '@/utils/supabase/client';
+
+/**
+ * 명함 ID에 해당하는 소셜 링크 목록 조회
+ *
+ * @param card_id 조회할 명함 ID
+ * @returns 명함에 연결된 소셜 링크 배열
+ */
+export const getCardSocialLists = async (card_id: string) => {
+  const supabase = await createClient();
+  const { data, error } = await supabase
+    .from(TABLES.LINKS)
+    .select('*')
+    .eq(DB_COLUMNS.LINKS.CARD_ID, card_id);
+
+  if (error) throw error;
+
+  return data;
+};
diff --git a/src/apis/interaction.ts b/src/apis/interaction.ts
new file mode 100644
index 00000000..f6f302f0
--- /dev/null
+++ b/src/apis/interaction.ts
@@ -0,0 +1,104 @@
+import { DB_COLUMNS, STORAGE, TABLES } from '@/constants/tables.constant';
+import { formatToDateString } from '@/utils/interaction/format-date';
+import { createClient } from '@/utils/supabase/client';
+
+interface LogInteractionParams {
+  cardId: string;
+  elementName: string | null;
+  type: 'click' | 'save' | null | undefined;
+  startedAt: string;
+  viewerIp: string;
+  sessionId: string;
+  source: 'direct' | 'qr' | 'link' | 'tag' | null;
+}
+
+/**
+ * 사용자 IP 주소 조회
+ */
+export const getIpAddress = async (): Promise<string> => {
+  try {
+    const res = await fetch('https://api.ipify.org?format=json');
+    if (!res.ok) {
+      throw new Error(`IP 주소 조회 실패: ${res.status} ${res.statusText}`);
+    }
+    const data = await res.json();
+    return data.ip;
+  } catch (error) {
+    console.error('IP 주소 조회 중 오류 발생:', error);
+    throw new Error('IP 주소를 가져오는 데 실패했습니다.');
+  }
+};
+
+/**
+ * 사용자 인터랙션 로깅
+ */
+export const logInteraction = async ({
+  cardId,
+  elementName,
+  type,
+  startedAt,
+  viewerIp,
+  sessionId,
+  source,
+}: LogInteractionParams) => {
+  const now = formatToDateString(new Date());
+
+  const supabase = await createClient();
+
+  const { data, error } = await supabase.from(TABLES.CARD_VIEWS).insert({
+    card_id: cardId,
+    element_name: elementName,
+    occurred_at: now,
+    started_at: startedAt,
+    end_at: now,
+    source: source,
+    type: type,
+    viewer_id: null,
+    viewer_ip: viewerIp,
+    session_id: sessionId,
+  });
+
+  if (error) throw error;
+  return data;
+};
+
+/**
+ * 세션 종료 처리
+ */
+export const endSession = async (sessionId: string, reason: string) => {
+  const now = formatToDateString(new Date());
+  const supabase = await createClient();
+
+  if (reason === 'page-exit') {
+    navigator.sendBeacon(
+      '/api/log-exit',
+      JSON.stringify({
+        session_id: sessionId,
+        end_at: now,
+      })
+    );
+    return { success: true };
+  } else {
+    const { error } = await supabase
+      .from(TABLES.CARD_VIEWS)
+      .update({ end_at: now })
+      .eq(DB_COLUMNS.CARD_VIEWS.SESSION_ID, sessionId);
+
+    if (error) throw error;
+    return { success: true };
+  }
+};
+
+/**
+ * 명함 이미지 다운로드
+ */
+export const downloadCardImage = async (cardId: string, fileName: string) => {
+  const supabase = await createClient();
+  const { data, error } = await supabase.storage
+    .from(STORAGE.CARDS)
+    .download(`${cardId}/${fileName}`);
+
+  if (error) throw error;
+
+  return data;
+};
diff --git a/src/app/api/log-exit/route.ts b/src/app/api/log-exit/route.ts
new file mode 100644
index 00000000..0ab211ed
--- /dev/null
+++ b/src/app/api/log-exit/route.ts
@@ -0,0 +1,39 @@
+import { DB_COLUMNS, TABLES } from '@/constants/tables.constant';
+import { createClient } from '@/utils/supabase/client';
+import { NextResponse } from 'next/server';
+
+/**
+ * 세션 종료 시 명함 조회 정보를 업데이트
+ *
+ * @param request
+ * @returns 성공 시 {success: true}, 실패 시 에러 메시지와 적절한 상태 코드
+ */
+export async function POST(request: Request) {
+  try {
+    // 요청 본문에서 세션 ID와 종료 시간 추출
+    const { session_id, end_at } = await request.json();
+    const supabase = await createClient();
+
+    if (!session_id || !end_at) {
+      return NextResponse.json(
+        { error: 'Missing required fields' },
+        { status: 400 }
+      );
+    }
+
+    const { error } = await supabase
+      .from(TABLES.CARD_VIEWS)
+      .update({ end_at })
+      .eq(DB_COLUMNS.CARD_VIEWS.SESSION_ID, session_id);
+
+    if (error) {
+      console.error('Supabase error:', error);
+      return NextResponse.json({ error: error.message }, { status: 500 });
+    }
+
+    return NextResponse.json({ success: true });
+  } catch (error) {
+    console.error('Error details:', error);
+    return NextResponse.json({ error: String(error) }, { status: 500 });
+  }
+}
diff --git a/src/components/card/flip-card.tsx b/src/components/card/flip-card.tsx
index 50721edf..3a1c24cc 100644
--- a/src/components/card/flip-card.tsx
+++ b/src/components/card/flip-card.tsx
@@ -1,7 +1,9 @@
 'use client';
 import clsx from 'clsx';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
 import FlipArrow from '@/components/icons/flip-arrow';
+import { usePathname, useSearchParams } from 'next/navigation';
+import { useInteractionTracker } from '@/hooks/use-interaction-tracker';
 
 interface FlipCardParam {
   attached?: boolean;
@@ -9,6 +11,7 @@ interface FlipCardParam {
 
 const FlipCard = ({ attached }: FlipCardParam) => {
   const [isFlipped, setIsFlipped] = useState(false);
+  const [startedAt, setStartedAt] = useState<Date | null>(null);
   const CARD_DEFAULT_STYLE =
     'bg-slate-700 absolute flex justify-center items-center backface-hidden w-full shadow-[37px_108px_32px_0px_rgba(0,0,0,0.00),24px_69px_29px_0px_rgba(0,0,0,0.01),13px_39px_25px_0px_rgba(0,0,0,0.05),6px_17px_18px_0px_rgba(0,0,0,0.09),1px_4px_10px_0px_rgba(0,0,0,0.10)] aspect-[9/5]';
   const CARD_DEFAULT_WRAPPER_STYLE =
@@ -16,6 +19,32 @@ const FlipCard = ({ attached }: FlipCardParam) => {
   const CARD_DEFAULT_BUTTON_STYLE = 'z-10 h-[34px] w-[34px] cursor-pointer';
   const CARD_DEFAULT_BUTTON_STYLE_ATTACHED = 'absolute bottom-0';
 
+  useEffect(() => {
+    setStartedAt(new Date());
+  }, []);
+
+  const pathname = usePathname();
+  const pathArray = pathname.split('/')[1];
+  const searchParams = useSearchParams();
+  const source = (() => {
+    const param = searchParams.get('source');
+    if (
+      param === 'direct' ||
+      param === 'qr' ||
+      param === 'link' ||
+      param === 'tag'
+    ) {
+      return param;
+    }
+    return null;
+  })();
+
+  const { handleClick, socialLinks } = useInteractionTracker({
+    slug: pathArray,
+    source,
+    startedAt,
+  });
+
   return (
     <div className='relative mb-4 flex w-full flex-col items-center justify-center'>
       <div className='m-5 aspect-[9/5] w-full perspective-1000'>
@@ -25,7 +54,24 @@ const FlipCard = ({ attached }: FlipCardParam) => {
             isFlipped && 'rotate-y-180'
           )}
         >
-          <div className={CARD_DEFAULT_STYLE}>front</div>
+          <div className={CARD_DEFAULT_STYLE}>
+            <div className='flex flex-col items-center justify-center'>
+              {socialLinks &&
+                socialLinks.map(({ platform, url }) => {
+                  if (!url) return;
+                  return (
+                    <button
+                      onClick={() =>
+                        handleClick({ url, elementName: platform })
+                      }
+                      key={platform}
+                    >
+                      {platform}
+                    </button>
+                  );
+                })}
+            </div>
+          </div>
           <div className={clsx(CARD_DEFAULT_STYLE, 'rotate-y-180')}>back</div>
         </div>
       </div>
diff --git a/src/constants/query-key.ts b/src/constants/query-key.ts
index 0bcbc9f1..753274b5 100644
--- a/src/constants/query-key.ts
+++ b/src/constants/query-key.ts
@@ -3,4 +3,7 @@ export const QUERY_KEY = {
   WEEK_CHART: 'week-chart',
   CLICK_TOTAL_CHART: 'click-total-chart',
   CARD_SELECT_LIST: 'card-select-list',
+  CARD_INTERACTION: 'card-interaction',
+  USER_IP: 'user-ip',
+  CARD_SOCIAL_LIST: 'card-social-list',
 };
diff --git a/src/constants/tables.constant.ts b/src/constants/tables.constant.ts
index 21c0aa53..7a2289ea 100644
--- a/src/constants/tables.constant.ts
+++ b/src/constants/tables.constant.ts
@@ -3,6 +3,7 @@ export const TABLES = {
   CARD_VIEWS: 'card_views',
   CARDS: 'cards',
   TEMPLATES: 'templates',
+  LINKS: 'links',
 } as const;
 
 export const SUB_TABLES = {
@@ -10,6 +11,14 @@ export const SUB_TABLES = {
   DAILY_CARD_SAVES: 'daily_card_saves',
 } as const;
 
+export const STORAGE = {
+  CARDS: 'cards',
+  QRCODES: 'qrcodes',
+  BGIMG: 'bgimg',
+  TEMPLATES: 'templates',
+  ICONS: 'icons',
+};
+
 export const DB_COLUMNS = {
   CARD_VIEWS: {
     ID: 'id',
@@ -39,6 +48,13 @@ export const DB_COLUMNS = {
     FRONT_IMG_URL: 'frontImgURL',
     BACK_IMG_URL: 'backImgURL',
   },
+  LINKS: {
+    ID: 'id',
+    CARD_ID: 'card_id',
+    PLATFORM: 'platform',
+    URL: 'url',
+    ICON_PATH: 'icon_path',
+  },
   DAILY_CARD_VIEWS: {
     VIEW_DATE: 'view_date',
     CARD_ID: 'card_id',
diff --git a/src/hooks/mutations/use-init-session.ts b/src/hooks/mutations/use-init-session.ts
new file mode 100644
index 00000000..dca2b38d
--- /dev/null
+++ b/src/hooks/mutations/use-init-session.ts
@@ -0,0 +1,128 @@
+import {
+  downloadCardImage,
+  endSession,
+  logInteraction,
+} from '@/apis/interaction';
+import { useMutation } from '@tanstack/react-query';
+import { formatToDateString } from '@/utils/interaction/format-date';
+
+interface SessionData {
+  sessionId: string;
+  startedAt: string;
+}
+
+/**
+ * 세션 초기화
+ *
+ * @param startedAt 세션 시작 시간
+ * @returns
+ */
+export const useInitSessionMutation = (startedAt: Date | null) => {
+  return useMutation({
+    mutationFn: async (): Promise<SessionData> => {
+      const existingSessionId =
+        sessionStorage.getItem('currentSessionId') ||
+        localStorage.getItem('backup_session_id') ||
+        null;
+
+      if (!startedAt) {
+        throw new Error('startedAt is required to initialize a session');
+      }
+
+      if (existingSessionId) {
+        return {
+          sessionId: existingSessionId,
+          startedAt:
+            sessionStorage.getItem('session_started_at') ||
+            formatToDateString(startedAt),
+        };
+      }
+
+      const startTime = formatToDateString(startedAt);
+      const uuid = crypto.randomUUID();
+
+      sessionStorage.setItem('currentSessionId', uuid);
+      sessionStorage.setItem('session_started_at', startTime);
+      localStorage.setItem('backup_session_id', uuid);
+
+      return { sessionId: uuid, startedAt: startTime };
+    },
+  });
+};
+
+/**
+ * 세션 종료를 위한 훅
+ */
+export const useEndSessionMutation = () => {
+  return useMutation({
+    mutationFn: ({
+      reason,
+      sessionId,
+    }: {
+      reason: string;
+      sessionId: string;
+    }) => {
+      return endSession(sessionId, reason);
+    },
+  });
+};
+
+/**
+ * 사용자 인터랙션 로깅
+ *
+ * @param cardId 명함 ID
+ * @param viewerIp 사용자 IP
+ * @param source 접근 출처 (direct, qr, link, tag 등)
+ * @param startedAtDate 시작 시간
+ * @returns
+ */
+export const useLogInteractionMutation = (
+  cardId: string,
+  viewerIp: string | undefined,
+  source: 'direct' | 'qr' | 'link' | 'tag' | null | undefined,
+  startedAtDate: Date | null
+) => {
+  return useMutation({
+    mutationFn: ({
+      elementName,
+      type,
+    }: {
+      elementName: string | null;
+      type: 'click' | 'save' | null | undefined;
+    }) => {
+      if (!viewerIp || !cardId) throw new Error('Missing required data');
+
+      const effectiveSessionId =
+        sessionStorage.getItem('currentSessionId') ||
+        localStorage.getItem('backup_session_id') ||
+        null;
+      if (!startedAtDate) {
+        throw new Error('startedAt is required to initialize a session');
+      }
+
+      if (!effectiveSessionId) throw new Error('No session ID available');
+
+      return logInteraction({
+        cardId,
+        elementName,
+        type,
+        startedAt: formatToDateString(startedAtDate),
+        viewerIp,
+        sessionId: effectiveSessionId,
+        source: source as 'direct' | 'qr' | 'link' | 'tag' | null,
+      });
+    },
+  });
+};
+
+/**
+ * 명함 이미지 다운로드
+ */
+export const useDownloadCardImageMutation = (
+  cardId: string,
+  fileName: string
+) => {
+  return useMutation({
+    mutationFn: () => downloadCardImage(cardId, fileName),
+  });
+};
diff --git a/src/hooks/queries/use-card-interaction.ts b/src/hooks/queries/use-card-interaction.ts
new file mode 100644
index 00000000..6a78d12a
--- /dev/null
+++ b/src/hooks/queries/use-card-interaction.ts
@@ -0,0 +1,19 @@
+import { getCardId } from '@/apis/card-interaction';
+import { QUERY_KEY } from '@/constants/query-key';
+import { useQuery } from '@tanstack/react-query';
+
+/**
+ * 슬러그를 기반으로 명함 ID를 조회하는 쿼리 훅
+ *
+ * @param slug 명함 슬러그 문자열
+ * @returns
+ */
+const useCardInteraction = (slug: string) => {
+  return useQuery({
+    queryKey: [QUERY_KEY.CARD_INTERACTION, slug],
+    queryFn: async () => getCardId(slug),
+    enabled: !!slug, // slug가 존재할 때만 쿼리 활성화
+  });
+};
+
+export default useCardInteraction;
diff --git a/src/hooks/queries/use-card-social-list.ts b/src/hooks/queries/use-card-social-list.ts
new file mode 100644
index 00000000..5f1199a8
--- /dev/null
+++ b/src/hooks/queries/use-card-social-list.ts
@@ -0,0 +1,19 @@
+import { getCardSocialLists } from '@/apis/card-social-list';
+import { QUERY_KEY } from '@/constants/query-key';
+import { useQuery } from '@tanstack/react-query';
+
+/**
+ * 명함의 소셜 링크 목록
+ *
+ * @param card_id 소셜 링크를 조회할 명함 ID
+ * @returns
+ */
+const useCardSocialList = (card_id: string) => {
+  return useQuery({
+    queryKey: [QUERY_KEY.CARD_SOCIAL_LIST, card_id],
+    queryFn: async () => getCardSocialLists(card_id),
+    enabled: !!card_id, // card_id가 존재할 때만 쿼리 활성화
+  });
+};
+
+export default useCardSocialList;
diff --git a/src/hooks/queries/use-ip-address.ts b/src/hooks/queries/use-ip-address.ts
new file mode 100644
index 00000000..b7862a58
--- /dev/null
+++ b/src/hooks/queries/use-ip-address.ts
@@ -0,0 +1,17 @@
+import { getIpAddress } from '@/apis/interaction';
+import { QUERY_KEY } from '@/constants/query-key';
+import { useQuery } from '@tanstack/react-query';
+
+/**
+ * 사용자 IP 주소를 조회하는 쿼리 훅
+ *
+ * @returns IP 주소 정보를 포함한 쿼리 결과
+ */
+export const useIpAddressQuery = () => {
+  return useQuery({
+    queryKey: [QUERY_KEY.USER_IP],
+    queryFn: getIpAddress,
+    staleTime: Infinity, // IP는 세션 동안 변경되지 않으므로 무한 캐시
+    retry: 3, // 실패 시 최대 3번 재시도
+  });
+};
diff --git a/src/hooks/use-interaction-tracker.ts b/src/hooks/use-interaction-tracker.ts
new file mode 100644
index 00000000..c4469201
--- /dev/null
+++ b/src/hooks/use-interaction-tracker.ts
@@ -0,0 +1,187 @@
+import { useEffect, useRef } from 'react';
+import { useIpAddressQuery } from './queries/use-ip-address';
+import {
+  useDownloadCardImageMutation,
+  useEndSessionMutation,
+  useInitSessionMutation,
+  useLogInteractionMutation,
+} from './mutations/use-init-session';
+import {
+  getEffectiveSessionId,
+  storePendingSessionEnd,
+} from '@/utils/interaction/session-util';
+import useCardInteraction from './queries/use-card-interaction';
+import useCardSocialList from './queries/use-card-social-list';
+
+interface InteractionProps {
+  slug: string;
+  source: 'direct' | 'qr' | 'link' | 'tag' | null | undefined;
+  startedAt: Date | null;
+}
+
+interface SocialLink {
+  elementName: string | null;
+  url: string | URL | undefined;
+}
+
+/**
+ * 인터랙션 추적 훅
+ */
+export const useInteractionTracker = ({
+  slug,
+  source,
+  startedAt,
+}: InteractionProps) => {
+  const { data: ip } = useIpAddressQuery();
+
+  const sessionInitMutation = useInitSessionMutation(startedAt);
+  const endSessionMutation = useEndSessionMutation();
+  const { data } = useCardInteraction(slug);
+  const cardId = data;
+  const { data: socialLinks } = useCardSocialList(cardId || '');
+
+  const logInteractionMutation = useLogInteractionMutation(
+    cardId || '',
+    ip,
+    source,
+    startedAt
+  );
+  // 더미 데이터 추가
+  const downloadCardImageMutation = useDownloadCardImageMutation(
+    'aabc7dc1-3991-4985-95bf-998a5425e0c8',
+    'card_test'
+  );
+
+  const inactivityTimerRef = useRef<NodeJS.Timeout | null>(null);
+  const lastActivityRef = useRef<Date | null>(null);
+
+  // 세션 초기화
+  useEffect(() => {
+    if (!ip || !cardId) return;
+    sessionInitMutation.mutate();
+  }, [ip, cardId]);
+
+  /**
+   * 활동 업데이트
+   */
+  const updateActivity = () => {
+    lastActivityRef.current = new Date();
+    if (inactivityTimerRef.current) {
+      clearTimeout(inactivityTimerRef.current);
+    }
+    inactivityTimerRef.current = setTimeout(
+      () => {
+        const sessionId = getEffectiveSessionId();
+        if (sessionId) {
+          endSessionMutation.mutate({ reason: 'inactive', sessionId });
+        }
+      },
+      30 * 60 * 1000
+    );
+  };
+
+  // 활동 이벤트 설정
+  useEffect(() => {
+    const sessionId = getEffectiveSessionId();
+    if (!sessionId) return;
+
+    const activityEvents = [
+      'mousemove',
+      'mousedown',
+      'keypress',
+      'scroll',
+      'touchstart',
+    ];
+
+    activityEvents.forEach((event) => {
+      window.addEventListener(event, updateActivity);
+    });
+
+    updateActivity();
+
+    const handleBeforeUnload = () => {
+      const currentSessionId = getEffectiveSessionId();
+      if (currentSessionId) {
+        storePendingSessionEnd(currentSessionId);
+        endSessionMutation.mutate({
+          reason: 'page-exit',
+          sessionId: currentSessionId,
+        });
+      }
+    };
+    window.addEventListener('beforeunload', handleBeforeUnload);
+
+    return () => {
+      activityEvents.forEach((event) => {
+        window.removeEventListener(event, updateActivity);
+      });
+      window.removeEventListener('beforeunload', handleBeforeUnload);
+
+      if (inactivityTimerRef.current) {
+        clearTimeout(inactivityTimerRef.current);
+      }
+
+      const currentSessionId = getEffectiveSessionId();
+      if (currentSessionId) {
+        endSessionMutation.mutate({
+          reason: 'route-change',
+          sessionId: currentSessionId,
+        });
+      }
+    };
+  }, []);
+
+  /**
+   * 이미지 저장 처리
+   */
+  const handleSaveImg = async () => {
+    try {
+      const data = await downloadCardImageMutation.mutateAsync();
+
+      const blob = new Blob([data], { type: 'image/jpeg' });
+      const url = URL.createObjectURL(blob);
+      const link = document.createElement('a');
+      link.href = url;
+      link.download = 'text.jpg';
+      document.body.appendChild(link);
+      link.click();
+      document.body.removeChild(link);
+      URL.revokeObjectURL(url);
+
+      logInteractionMutation.mutate({ elementName: 'image', type: 'save' });
+      updateActivity();
+    } catch (error) {
+      console.error(error);
+    }
+  };
+
+  /**
+   * vCard 저장 처리
+   */
+  const handleSaveVCard = async () => {
+    try {
+      logInteractionMutation.mutate({ elementName: 'vcard', type: 'save' });
+      updateActivity();
+    } catch (error) {
+      console.error(error);
+    }
+  };
+
+  /**
+   * 소셜 링크 클릭 처리
+   */
+  const handleClick = async ({ url, elementName }: SocialLink) => {
+    logInteractionMutation.mutate({ elementName, type: 'click' });
+    window.open(url, '_blank');
+    updateActivity();
+  };
+
+  return {
+    handleClick,
+    handleSaveImg,
+    socialLinks,
+    handleSaveVCard,
+    isLoading: !ip || sessionInitMutation.isPending,
+    isError: sessionInitMutation.isError || logInteractionMutation.isError,
+  };
+};
diff --git a/src/types/supabase.ts b/src/types/supabase.ts
index 0abfbc80..55db1b9f 100644
--- a/src/types/supabase.ts
+++ b/src/types/supabase.ts
@@ -4,370 +4,370 @@ export type Json =
   | boolean
   | null
   | { [key: string]: Json | undefined }
-  | Json[]
+  | Json[];
 
 export type Database = {
   public: {
     Tables: {
       card_views: {
         Row: {
-          card_id: string
-          duration: number | null
-          element_name: string | null
-          end_at: string | null
-          id: string
-          occurred_at: string | null
-          session_id: string | null
-          source: Database["public"]["Enums"]["card_views_source"] | null
-          started_at: string | null
-          type: Database["public"]["Enums"]["interactions_type"] | null
-          viewer_id: string | null
-          viewer_ip: string | null
-        }
+          card_id: string;
+          duration: number | null;
+          element_name: string | null;
+          end_at: string | null;
+          id: string;
+          occurred_at: string | null;
+          session_id: string | null;
+          source: Database['public']['Enums']['card_views_source'] | null;
+          started_at: string | null;
+          type: Database['public']['Enums']['interactions_type'] | null;
+          viewer_id: string | null;
+          viewer_ip: string | null;
+        };
         Insert: {
-          card_id: string
-          duration?: number | null
-          element_name?: string | null
-          end_at?: string | null
-          id?: string
-          occurred_at?: string | null
-          session_id?: string | null
-          source?: Database["public"]["Enums"]["card_views_source"] | null
-          started_at?: string | null
-          type?: Database["public"]["Enums"]["interactions_type"] | null
-          viewer_id?: string | null
-          viewer_ip?: string | null
-        }
+          card_id: string;
+          duration?: number | null;
+          element_name?: string | null;
+          end_at?: string | null;
+          id?: string;
+          occurred_at?: string | null;
+          session_id?: string | null;
+          source?: Database['public']['Enums']['card_views_source'] | null;
+          started_at?: string | null;
+          type?: Database['public']['Enums']['interactions_type'] | null;
+          viewer_id?: string | null;
+          viewer_ip?: string | null;
+        };
         Update: {
-          card_id?: string
-          duration?: number | null
-          element_name?: string | null
-          end_at?: string | null
-          id?: string
-          occurred_at?: string | null
-          session_id?: string | null
-          source?: Database["public"]["Enums"]["card_views_source"] | null
-          started_at?: string | null
-          type?: Database["public"]["Enums"]["interactions_type"] | null
-          viewer_id?: string | null
-          viewer_ip?: string | null
-        }
+          card_id?: string;
+          duration?: number | null;
+          element_name?: string | null;
+          end_at?: string | null;
+          id?: string;
+          occurred_at?: string | null;
+          session_id?: string | null;
+          source?: Database['public']['Enums']['card_views_source'] | null;
+          started_at?: string | null;
+          type?: Database['public']['Enums']['interactions_type'] | null;
+          viewer_id?: string | null;
+          viewer_ip?: string | null;
+        };
         Relationships: [
           {
-            foreignKeyName: "card_views_card_id_fkey"
-            columns: ["card_id"]
-            isOneToOne: false
-            referencedRelation: "cards"
-            referencedColumns: ["id"]
+            foreignKeyName: 'card_views_card_id_fkey';
+            columns: ['card_id'];
+            isOneToOne: false;
+            referencedRelation: 'cards';
+            referencedColumns: ['id'];
           },
           {
-            foreignKeyName: "card_views_viewer_id_fkey"
-            columns: ["viewer_id"]
-            isOneToOne: false
-            referencedRelation: "users"
-            referencedColumns: ["id"]
+            foreignKeyName: 'card_views_viewer_id_fkey';
+            columns: ['viewer_id'];
+            isOneToOne: false;
+            referencedRelation: 'users';
+            referencedColumns: ['id'];
           },
-        ]
-      }
+        ];
+      };
       cards: {
         Row: {
-          back_content: Json | null
-          backImgURL: string | null
-          created_at: string
-          front_content: Json | null
-          frontImgURL: string | null
-          id: string
-          slug: string
-          status: Database["public"]["Enums"]["cards_status"] | null
-          template_id: string
-          title: string
-          updated_at: string | null
-          user_id: string | null
-        }
+          back_content: Json | null;
+          backImgURL: string | null;
+          created_at: string;
+          front_content: Json | null;
+          frontImgURL: string | null;
+          id: string;
+          slug: string;
+          status: Database['public']['Enums']['cards_status'] | null;
+          template_id: string;
+          title: string;
+          updated_at: string | null;
+          user_id: string | null;
+        };
         Insert: {
-          back_content?: Json | null
-          backImgURL?: string | null
-          created_at?: string
-          front_content?: Json | null
-          frontImgURL?: string | null
-          id?: string
-          slug: string
-          status?: Database["public"]["Enums"]["cards_status"] | null
-          template_id: string
-          title: string
-          updated_at?: string | null
-          user_id?: string | null
-        }
+          back_content?: Json | null;
+          backImgURL?: string | null;
+          created_at?: string;
+          front_content?: Json | null;
+          frontImgURL?: string | null;
+          id?: string;
+          slug: string;
+          status?: Database['public']['Enums']['cards_status'] | null;
+          template_id: string;
+          title: string;
+          updated_at?: string | null;
+          user_id?: string | null;
+        };
         Update: {
-          back_content?: Json | null
-          backImgURL?: string | null
-          created_at?: string
-          front_content?: Json | null
-          frontImgURL?: string | null
-          id?: string
-          slug?: string
-          status?: Database["public"]["Enums"]["cards_status"] | null
-          template_id?: string
-          title?: string
-          updated_at?: string | null
-          user_id?: string | null
-        }
+          back_content?: Json | null;
+          backImgURL?: string | null;
+          created_at?: string;
+          front_content?: Json | null;
+          frontImgURL?: string | null;
+          id?: string;
+          slug?: string;
+          status?: Database['public']['Enums']['cards_status'] | null;
+          template_id?: string;
+          title?: string;
+          updated_at?: string | null;
+          user_id?: string | null;
+        };
         Relationships: [
           {
-            foreignKeyName: "cards_template_id_fkey"
-            columns: ["template_id"]
-            isOneToOne: false
-            referencedRelation: "templates"
-            referencedColumns: ["id"]
+            foreignKeyName: 'cards_template_id_fkey';
+            columns: ['template_id'];
+            isOneToOne: false;
+            referencedRelation: 'templates';
+            referencedColumns: ['id'];
           },
           {
-            foreignKeyName: "cards_user_id_fkey1"
-            columns: ["user_id"]
-            isOneToOne: false
-            referencedRelation: "users"
-            referencedColumns: ["id"]
+            foreignKeyName: 'cards_user_id_fkey1';
+            columns: ['user_id'];
+            isOneToOne: false;
+            referencedRelation: 'users';
+            referencedColumns: ['id'];
           },
-        ]
-      }
+        ];
+      };
       links: {
         Row: {
-          card_id: string
-          icon_path: string | null
-          id: string
-          platform: string | null
-          url: string | null
-        }
+          card_id: string;
+          icon_path: string | null;
+          id: string;
+          platform: string | null;
+          url: string | null;
+        };
         Insert: {
-          card_id?: string
-          icon_path?: string | null
-          id?: string
-          platform?: string | null
-          url?: string | null
-        }
+          card_id?: string;
+          icon_path?: string | null;
+          id?: string;
+          platform?: string | null;
+          url?: string | null;
+        };
         Update: {
-          card_id?: string
-          icon_path?: string | null
-          id?: string
-          platform?: string | null
-          url?: string | null
-        }
+          card_id?: string;
+          icon_path?: string | null;
+          id?: string;
+          platform?: string | null;
+          url?: string | null;
+        };
         Relationships: [
           {
-            foreignKeyName: "links_card_id_fkey"
-            columns: ["card_id"]
-            isOneToOne: false
-            referencedRelation: "cards"
-            referencedColumns: ["id"]
+            foreignKeyName: 'links_card_id_fkey';
+            columns: ['card_id'];
+            isOneToOne: false;
+            referencedRelation: 'cards';
+            referencedColumns: ['id'];
           },
-        ]
-      }
+        ];
+      };
       templates: {
         Row: {
-          id: string
-          name: string
-          structure: Json
-          style: Database["public"]["Enums"]["templates_style"] | null
-          thumbnail: string | null
-        }
+          id: string;
+          name: string;
+          structure: Json;
+          style: Database['public']['Enums']['templates_style'] | null;
+          thumbnail: string | null;
+        };
         Insert: {
-          id?: string
-          name: string
-          structure: Json
-          style?: Database["public"]["Enums"]["templates_style"] | null
-          thumbnail?: string | null
-        }
+          id?: string;
+          name: string;
+          structure: Json;
+          style?: Database['public']['Enums']['templates_style'] | null;
+          thumbnail?: string | null;
+        };
         Update: {
-          id?: string
-          name?: string
-          structure?: Json
-          style?: Database["public"]["Enums"]["templates_style"] | null
-          thumbnail?: string | null
-        }
-        Relationships: []
-      }
+          id?: string;
+          name?: string;
+          structure?: Json;
+          style?: Database['public']['Enums']['templates_style'] | null;
+          thumbnail?: string | null;
+        };
+        Relationships: [];
+      };
       users: {
         Row: {
-          created_at: string
-          email: string
-          id: string
-          nick_name: string
-        }
+          created_at: string;
+          email: string;
+          id: string;
+          nick_name: string;
+        };
         Insert: {
-          created_at?: string
-          email: string
-          id?: string
-          nick_name: string
-        }
+          created_at?: string;
+          email: string;
+          id?: string;
+          nick_name: string;
+        };
         Update: {
-          created_at?: string
-          email?: string
-          id?: string
-          nick_name?: string
-        }
-        Relationships: []
-      }
-    }
+          created_at?: string;
+          email?: string;
+          id?: string;
+          nick_name?: string;
+        };
+        Relationships: [];
+      };
+    };
     Views: {
       daily_card_saves: {
         Row: {
-          card_id: string | null
-          save_date: string | null
-          unique_saves: number | null
-        }
+          card_id: string | null;
+          save_date: string | null;
+          unique_saves: number | null;
+        };
         Relationships: [
           {
-            foreignKeyName: "card_views_card_id_fkey"
-            columns: ["card_id"]
-            isOneToOne: false
-            referencedRelation: "cards"
-            referencedColumns: ["id"]
+            foreignKeyName: 'card_views_card_id_fkey';
+            columns: ['card_id'];
+            isOneToOne: false;
+            referencedRelation: 'cards';
+            referencedColumns: ['id'];
           },
-        ]
-      }
+        ];
+      };
       daily_card_views: {
         Row: {
-          card_id: string | null
-          unique_sessions: number | null
-          view_date: string | null
-        }
+          card_id: string | null;
+          unique_sessions: number | null;
+          view_date: string | null;
+        };
         Relationships: [
           {
-            foreignKeyName: "card_views_card_id_fkey"
-            columns: ["card_id"]
-            isOneToOne: false
-            referencedRelation: "cards"
-            referencedColumns: ["id"]
+            foreignKeyName: 'card_views_card_id_fkey';
+            columns: ['card_id'];
+            isOneToOne: false;
+            referencedRelation: 'cards';
+            referencedColumns: ['id'];
           },
-        ]
-      }
-    }
+        ];
+      };
+    };
     Functions: {
-      [_ in never]: never
-    }
+      [_ in never]: never;
+    };
     Enums: {
-      card_views_source: "direct" | "qr" | "link" | "iframe"
-      cards_status: "draft" | "published"
-      interactions_type: "click" | "save"
-      templates_style: "simple" | "trendy"
-    }
+      card_views_source: 'direct' | 'qr' | 'link' | 'tag';
+      cards_status: 'draft' | 'published';
+      interactions_type: 'click' | 'save';
+      templates_style: 'simple' | 'trendy';
+    };
     CompositeTypes: {
-      [_ in never]: never
-    }
-  }
-}
+      [_ in never]: never;
+    };
+  };
+};
 
-type DefaultSchema = Database[Extract<keyof Database, "public">]
+type DefaultSchema = Database[Extract<keyof Database, 'public'>];
 
 export type Tables<
   DefaultSchemaTableNameOrOptions extends
-    | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
+    | keyof (DefaultSchema['Tables'] & DefaultSchema['Views'])
     | { schema: keyof Database },
   TableName extends DefaultSchemaTableNameOrOptions extends {
-    schema: keyof Database
+    schema: keyof Database;
   }
-    ? keyof (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
-        Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
+    ? keyof (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
+        Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])
     : never = never,
 > = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
-  ? (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
-      Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
-      Row: infer R
+  ? (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
+      Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends {
+      Row: infer R;
     }
     ? R
     : never
-  : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
-        DefaultSchema["Views"])
-    ? (DefaultSchema["Tables"] &
-        DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
-        Row: infer R
+  : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] &
+        DefaultSchema['Views'])
+    ? (DefaultSchema['Tables'] &
+        DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends {
+        Row: infer R;
       }
       ? R
       : never
-    : never
+    : never;
 
 export type TablesInsert<
   DefaultSchemaTableNameOrOptions extends
-    | keyof DefaultSchema["Tables"]
+    | keyof DefaultSchema['Tables']
     | { schema: keyof Database },
   TableName extends DefaultSchemaTableNameOrOptions extends {
-    schema: keyof Database
+    schema: keyof Database;
   }
-    ? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
+    ? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables']
     : never = never,
 > = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
-  ? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
-      Insert: infer I
+  ? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
+      Insert: infer I;
     }
     ? I
     : never
-  : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
-    ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
-        Insert: infer I
+  : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
+    ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
+        Insert: infer I;
       }
       ? I
       : never
-    : never
+    : never;
 
 export type TablesUpdate<
   DefaultSchemaTableNameOrOptions extends
-    | keyof DefaultSchema["Tables"]
+    | keyof DefaultSchema['Tables']
     | { schema: keyof Database },
   TableName extends DefaultSchemaTableNameOrOptions extends {
-    schema: keyof Database
+    schema: keyof Database;
   }
-    ? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
+    ? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables']
     : never = never,
 > = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
-  ? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
-      Update: infer U
+  ? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
+      Update: infer U;
     }
     ? U
     : never
-  : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
-    ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
-        Update: infer U
+  : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables']
+    ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends {
+        Update: infer U;
       }
       ? U
       : never
-    : never
+    : never;
 
 export type Enums<
   DefaultSchemaEnumNameOrOptions extends
-    | keyof DefaultSchema["Enums"]
+    | keyof DefaultSchema['Enums']
     | { schema: keyof Database },
   EnumName extends DefaultSchemaEnumNameOrOptions extends {
-    schema: keyof Database
+    schema: keyof Database;
   }
-    ? keyof Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
+    ? keyof Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums']
     : never = never,
 > = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
-  ? Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
-  : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
-    ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
-    : never
+  ? Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName]
+  : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums']
+    ? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions]
+    : never;
 
 export type CompositeTypes<
   PublicCompositeTypeNameOrOptions extends
-    | keyof DefaultSchema["CompositeTypes"]
+    | keyof DefaultSchema['CompositeTypes']
     | { schema: keyof Database },
   CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
-    schema: keyof Database
+    schema: keyof Database;
   }
-    ? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
+    ? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes']
     : never = never,
 > = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
-  ? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
-  : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
-    ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
-    : never
+  ? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName]
+  : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes']
+    ? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions]
+    : never;
 
 export const Constants = {
   public: {
     Enums: {
-      card_views_source: ["direct", "qr", "link", "iframe"],
-      cards_status: ["draft", "published"],
-      interactions_type: ["click", "save"],
-      templates_style: ["simple", "trendy"],
+      card_views_source: ['direct', 'qr', 'link', 'tag'],
+      cards_status: ['draft', 'published'],
+      interactions_type: ['click', 'save'],
+      templates_style: ['simple', 'trendy'],
     },
   },
-} as const
+} as const;
diff --git a/src/utils/interaction/format-date.ts b/src/utils/interaction/format-date.ts
new file mode 100644
index 00000000..dbbdb918
--- /dev/null
+++ b/src/utils/interaction/format-date.ts
@@ -0,0 +1,21 @@
+/**
+ * 주어진 날짜를 yyyy-mm-dd 형식으로 변환하는 함수입니다.
+ *
+ * @param {Date} date - 변환할 Date 객체.
+ * @returns {string} yyyy-mm-dd 형식으로 변환된 날짜 문자열.
+ */
+export const formatToDateString = (date: Date): string => {
+  const year = date.getFullYear();
+  const month = (date.getMonth() + 1).toString().padStart(2, '0');
+  const day = date.getDate().toString().padStart(2, '0');
+
+  const formattedDate = `${year}-${month}-${day}`;
+
+  const hours = date.getHours().toString().padStart(2, '0');
+  const minutes = date.getMinutes().toString().padStart(2, '0');
+  const seconds = date.getSeconds().toString().padStart(2, '0');
+
+  const formattedTime = `${hours}:${minutes}:${seconds}`;
+
+  return formattedDate + ' ' + formattedTime;
+};
diff --git a/src/utils/interaction/session-util.ts b/src/utils/interaction/session-util.ts
new file mode 100644
index 00000000..4d28e91a
--- /dev/null
+++ b/src/utils/interaction/session-util.ts
@@ -0,0 +1,34 @@
+import { formatToDateString } from './format-date';
+
+/**
+ * 유효한 세션 ID를 가져옴
+ */
+export const getEffectiveSessionId = (): string | null => {
+  return (
+    sessionStorage.getItem('currentSessionId') ||
+    localStorage.getItem('backup_session_id') ||
+    null
+  );
+};
+
+/**
+ * 세션 종료 데이터 저장
+ */
+export const storePendingSessionEnd = (sessionId: string): void => {
+  localStorage.setItem(
+    'pending_end_session',
+    JSON.stringify({
+      session_id: sessionId,
+      end_at: formatToDateString(new Date()),
+      timestamp: Date.now(),
+    })
+  );
+};
+
+/**
+ * 세션 데이터 삭제
+ */
+export const clearSessionData = (): void => {
+  sessionStorage.removeItem('currentSessionId');
+  sessionStorage.removeItem('session_started_at');
+};


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 9**

<details>
<summary>🧹 Nitpick comments (12)</summary><blockquote>

<details>
<summary>src/apis/card-social-list.ts (2)</summary><blockquote>

`10-20`: **데이터 유효성 검사 및 빈 배열 처리 고려**

`getCardSocialLists` 함수는 기본적인 기능을 잘 수행하지만, 결과가 없을 때의 처리가 명시적이지 않습니다. 데이터가 없는 경우 빈 배열을 반환할 것으로 예상됩니다만, 이를 명확히 하거나 추가 처리가 필요할 수 있습니다.


결과가 없을 때의 처리를 명시적으로 추가할 수 있습니다:

```diff
  if (error) throw error;

+ // 데이터가 없을 경우 빈 배열 반환
+ if (!data) return [];

  return data;
```

또는 콘솔에 로그를 추가하여 디버깅을 쉽게 할 수 있습니다:

```diff
  if (error) throw error;

+ // 소셜 링크 조회 결과 로깅
+ console.log(`카드 ID ${card_id}에 대한 소셜 링크 ${data?.length || 0}개 조회됨`);

  return data;
```

---

`1-2`: **타입 정의 개선 고려**

현재 함수는 반환 타입이 명시적으로 지정되어 있지 않아, 반환 데이터의 구조를 파악하기 어렵습니다.


인터페이스를 정의하고 반환 타입을 명시적으로 지정하는 것이 좋습니다:

```diff
import { DB_COLUMNS, TABLES } from '@/constants/tables.constant';
import { createClient } from '@/utils/supabase/client';

+ // 소셜 링크 인터페이스 정의
+ export interface CardSocialLink {
+   id: string;
+   card_id: string;
+   platform: string;
+   url: string;
+   icon_path: string;
+ }

/**
 * 명함 ID에 해당하는 소셜 링크 목록 조회
 *
 * @param card_id 조회할 명함 ID
 * @returns 명함에 연결된 소셜 링크 배열
 */
- export const getCardSocialLists = async (card_id: string) => {
+ export const getCardSocialLists = async (card_id: string): Promise<CardSocialLink[]> => {
```

</blockquote></details>
<details>
<summary>src/hooks/queries/use-card-social-list.ts (1)</summary><blockquote>

`5-10`: **JSDoc 주석의 @returns 설명이 누락되었습니다.**

JSDoc 주석에 @returns 태그 다음에 반환값에 대한 설명이 없습니다. 완전한 문서화를 위해 반환값에 대한 설명을 추가하는 것이 좋습니다.


```diff
/**
 * 명함의 소셜 링크 목록
 *
 * @param card_id 소셜 링크를 조회할 명함 ID
- * @returns
+ * @returns 명함의 소셜 링크 목록을 포함한 쿼리 결과
 */
```

</blockquote></details>
<details>
<summary>src/hooks/queries/use-card-interaction.ts (1)</summary><blockquote>

`5-10`: **JSDoc 주석의 @returns 설명이 누락되었습니다.**

JSDoc 주석에 @returns 태그 다음에 반환값에 대한 설명이 없습니다. 완전한 문서화를 위해 반환값에 대한 설명을 추가하는 것이 좋습니다.


```diff
/**
 * 슬러그를 기반으로 명함 ID를 조회하는 쿼리 훅
 *
 * @param slug 명함 슬러그 문자열
- * @returns
+ * @returns 명함 ID를 포함한 쿼리 결과
 */
```

</blockquote></details>
<details>
<summary>src/utils/interaction/session-util.ts (1)</summary><blockquote>

`14-34`: **잠재적인 개선 사항: localStorage에서 pending_end_session 처리 로직 추가**

`storePendingSessionEnd` 함수는 종료 정보를 저장하지만, 이 정보를 다시 읽거나 처리하는 로직은 이 파일에 포함되어 있지 않습니다. 완전성을 위해 `getPendingSessionEnd`와 같은 함수를 추가하여 저장된 데이터를 안전하게 파싱하고 반환하는 것이 좋을 수 있습니다.


```typescript
/**
 * 저장된 세션 종료 데이터 가져오기
 */
export const getPendingSessionEnd = () => {
  const data = localStorage.getItem('pending_end_session');
  if (!data) return null;
  
  try {
    return JSON.parse(data) as {
      session_id: string;
      end_at: string;
      timestamp: number;
    };
  } catch (e) {
    console.error('Failed to parse pending session end data', e);
    localStorage.removeItem('pending_end_session');
    return null;
  }
};
```

</blockquote></details>
<details>
<summary>src/app/api/log-exit/route.ts (2)</summary><blockquote>

`24-32`: **업데이트 결과 검증을 고려해 보세요**  
`supabase.from(...).update(...)`의 결과로 실제 업데이트된 레코드 수가 0일 경우, 세션이 존재하지 않는다는 사실을 추가로 알려줄 수도 있습니다.  

```diff
 .eq(DB_COLUMNS.CARD_VIEWS.SESSION_ID, session_id)
+ .select()
 
 // 업데이트 결과로 반환된 레코드가 비어있다면 세션 미존재 등의 안내가 가능
```

---

`34-38`: **예외 처리 시 추가 로그 정보를 남기는 것을 고려하세요**  
현재 모든 예외를 500으로 통일하여 반환하고 있지만, 디버깅 편의를 위해 에러 유형이나 자세한 스택 트레이스가 필요할 수 있습니다.

</blockquote></details>
<details>
<summary>src/components/card/flip-card.tsx (2)</summary><blockquote>

`26-40`: **URL 파라미터를 통한 source 식별이 명확합니다**  
직접, QR, 링크, 태그 등 다양한 유입 경로를 구분하기 편리합니다.  

source가 `null`인 경우도 로그로 기록할지 고민해 보시길 권장드립니다.

---

`59-72`: **optional chain을 활용하여 안전성을 높일 수 있습니다**  
`socialLinks && socialLinks.map` 대신 `socialLinks?.map`를 사용하면 조건문을 줄이고 코드 가독성을 높일 수 있습니다.  

```diff
-{socialLinks &&
-  socialLinks.map(({ platform, url }) => {
+{socialLinks?.map(({ platform, url }) => {
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Biome (1.9.4)</summary>

[error] 59-72: Change to an optional chain.

Unsafe fix: Change to an optional chain.


(lint/complexity/useOptionalChain)

</details>

</details>

</blockquote></details>
<details>
<summary>src/hooks/use-interaction-tracker.ts (2)</summary><blockquote>

`70-75`: **비활동 시 세션 종료 처리**  
30분간 활동이 없으면 세션을 종료하는 로직이 유용합니다.  

지속시간(30분)을 별도 상수로 추출하여 다른 곳에서 쉽게 재활용할 수 있도록 고려해 보세요.

---

`130-146`: **다운로드되는 이미지 이름을 유의하세요**  
현재 `'text.jpg'`라는 고정된 이름으로 저장되고 있으니, 카드 이름 등 의미 있는 파일명으로 변경하는 편이 좋습니다.  

```diff
- link.download = 'text.jpg';
+ link.download = 'my-card.jpg';
```

</blockquote></details>
<details>
<summary>src/hooks/mutations/use-init-session.ts (1)</summary><blockquote>

`14-51`: **세션 초기화 로직 개선 제안**

세션 초기화 함수 구현이 잘 되어 있으나, 다음 몇 가지 개선사항을 고려해보세요:

1. 세션 ID 존재 여부 체크와 startedAt 체크 순서를 바꾸어 더 효율적으로 만들 수 있습니다.
2. localStorage와 sessionStorage를 일관되게 사용하는 유틸 함수를 만들어 중복 코드를 줄일 수 있습니다.


```diff
export const useInitSessionMutation = (startedAt: Date | null) => {
  return useMutation({
    mutationFn: async (): Promise<SessionData> => {
+     if (!startedAt) {
+       throw new Error('startedAt is required to initialize a session');
+     }

      const existingSessionId =
        sessionStorage.getItem('currentSessionId') ||
        localStorage.getItem('backup_session_id') ||
        null;

-     if (!startedAt) {
-       throw new Error('startedAt is required to initialize a session');
-     }

      if (existingSessionId) {
        return {
          sessionId: existingSessionId,
          startedAt:
            sessionStorage.getItem('session_started_at') ||
            formatToDateString(startedAt),
        };
      }

      const startTime = formatToDateString(startedAt);
      const uuid = crypto.randomUUID();

      sessionStorage.setItem('currentSessionId', uuid);
      sessionStorage.setItem('session_started_at', startTime);
      localStorage.setItem('backup_session_id', uuid);

      return { sessionId: uuid, startedAt: startTime };
    },
  });
};
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 912197f886fb2826bf6c14840dcec4a15e752ca5 and e74829b9f341f82a19c16d336001dc109888f871.

</details>

<details>
<summary>📒 Files selected for processing (14)</summary>

* `src/apis/card-interaction.ts` (1 hunks)
* `src/apis/card-social-list.ts` (1 hunks)
* `src/apis/interaction.ts` (1 hunks)
* `src/app/api/log-exit/route.ts` (1 hunks)
* `src/components/card/flip-card.tsx` (2 hunks)
* `src/constants/query-key.ts` (1 hunks)
* `src/constants/tables.constant.ts` (2 hunks)
* `src/hooks/mutations/use-init-session.ts` (1 hunks)
* `src/hooks/queries/use-card-interaction.ts` (1 hunks)
* `src/hooks/queries/use-card-social-list.ts` (1 hunks)
* `src/hooks/queries/use-ip-address.ts` (1 hunks)
* `src/hooks/use-interaction-tracker.ts` (1 hunks)
* `src/utils/interaction/format-date.ts` (1 hunks)
* `src/utils/interaction/session-util.ts` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (11)</summary>

<details>
<summary>src/apis/card-interaction.ts (1)</summary><blockquote>

<details>
<summary>src/constants/tables.constant.ts (2)</summary>

* `TABLES` (1-7)
* `DB_COLUMNS` (22-68)

</details>

</blockquote></details>
<details>
<summary>src/apis/card-social-list.ts (1)</summary><blockquote>

<details>
<summary>src/constants/tables.constant.ts (2)</summary>

* `TABLES` (1-7)
* `DB_COLUMNS` (22-68)

</details>

</blockquote></details>
<details>
<summary>src/hooks/queries/use-card-social-list.ts (2)</summary><blockquote>

<details>
<summary>src/constants/query-key.ts (1)</summary>

* `QUERY_KEY` (1-9)

</details>
<details>
<summary>src/apis/card-social-list.ts (1)</summary>

* `getCardSocialLists` (10-20)

</details>

</blockquote></details>
<details>
<summary>src/utils/interaction/session-util.ts (1)</summary><blockquote>

<details>
<summary>src/utils/interaction/format-date.ts (1)</summary>

* `formatToDateString` (7-16)

</details>

</blockquote></details>
<details>
<summary>src/app/api/log-exit/route.ts (1)</summary><blockquote>

<details>
<summary>src/constants/tables.constant.ts (2)</summary>

* `TABLES` (1-7)
* `DB_COLUMNS` (22-68)

</details>

</blockquote></details>
<details>
<summary>src/hooks/queries/use-ip-address.ts (2)</summary><blockquote>

<details>
<summary>src/constants/query-key.ts (1)</summary>

* `QUERY_KEY` (1-9)

</details>
<details>
<summary>src/apis/interaction.ts (1)</summary>

* `getIpAddress` (18-22)

</details>

</blockquote></details>
<details>
<summary>src/hooks/use-interaction-tracker.ts (3)</summary><blockquote>

<details>
<summary>src/hooks/queries/use-ip-address.ts (1)</summary>

* `useIpAddressQuery` (10-17)

</details>
<details>
<summary>src/hooks/mutations/use-init-session.ts (4)</summary>

* `useInitSessionMutation` (20-51)
* `useEndSessionMutation` (56-68)
* `useLogInteractionMutation` (79-116)
* `useDownloadCardImageMutation` (121-125)

</details>
<details>
<summary>src/utils/interaction/session-util.ts (2)</summary>

* `getEffectiveSessionId` (6-12)
* `storePendingSessionEnd` (17-26)

</details>

</blockquote></details>
<details>
<summary>src/components/card/flip-card.tsx (1)</summary><blockquote>

<details>
<summary>src/hooks/use-interaction-tracker.ts (1)</summary>

* `useInteractionTracker` (30-183)

</details>

</blockquote></details>
<details>
<summary>src/hooks/mutations/use-init-session.ts (2)</summary><blockquote>

<details>
<summary>src/utils/interaction/format-date.ts (1)</summary>

* `formatToDateString` (7-16)

</details>
<details>
<summary>src/apis/interaction.ts (3)</summary>

* `endSession` (60-82)
* `logInteraction` (27-55)
* `downloadCardImage` (87-97)

</details>

</blockquote></details>
<details>
<summary>src/hooks/queries/use-card-interaction.ts (2)</summary><blockquote>

<details>
<summary>src/constants/query-key.ts (1)</summary>

* `QUERY_KEY` (1-9)

</details>
<details>
<summary>src/apis/card-interaction.ts (1)</summary>

* `getCardId` (10-22)

</details>

</blockquote></details>
<details>
<summary>src/apis/interaction.ts (2)</summary><blockquote>

<details>
<summary>src/utils/interaction/format-date.ts (1)</summary>

* `formatToDateString` (7-16)

</details>
<details>
<summary>src/constants/tables.constant.ts (3)</summary>

* `TABLES` (1-7)
* `DB_COLUMNS` (22-68)
* `STORAGE` (14-20)

</details>

</blockquote></details>

</details><details>
<summary>🪛 Biome (1.9.4)</summary>

<details>
<summary>src/components/card/flip-card.tsx</summary>

[error] 59-72: Change to an optional chain.

Unsafe fix: Change to an optional chain.


(lint/complexity/useOptionalChain)

</details>

</details>

</details>

<details>
<summary>🔇 Additional comments (20)</summary><blockquote>

<details>
<summary>src/constants/query-key.ts (1)</summary>

`6-8`: **LGTM! 쿼리 키 추가가 적절합니다.**

새로 추가된 쿼리 키들이 기존 명명 규칙을 잘 따르고 있으며, 의도된 목적에 맞게 명확하게 정의되어 있습니다.

</details>
<details>
<summary>src/hooks/queries/use-ip-address.ts (1)</summary>

`1-17`: **코드가 깔끔하고 잘 구현되었습니다!**

이 훅은 사용자의 IP 주소를 조회하는 기능을 적절하게 구현했습니다. react-query의 모범 사례를 잘 따르고 있으며, 특히 다음과 같은 설정이 잘 되어 있습니다:

- `staleTime: Infinity`: IP 주소는 세션 동안 변경되지 않으므로 무한 캐시 설정이 적절합니다.
- `retry: 3`: 네트워크 실패 시 재시도 횟수가 합리적으로 설정되었습니다.

JSDoc 주석도 명확하게 작성되어 있어 훅의 목적과 반환값을 쉽게 이해할 수 있습니다.

</details>
<details>
<summary>src/utils/interaction/session-util.ts (3)</summary>

`6-12`: **코드가 깔끔하게 작성되었습니다!**

세션 ID를 가져오는 함수가 잘 구현되어 있습니다. sessionStorage와 localStorage에서 순차적으로 값을 확인하는 방식이 적절합니다.

---

`17-26`: **pending_end_session 저장 구현이 잘 되었습니다.**

세션 종료 데이터를 localStorage에 저장하는 로직이 명확하게 구현되어 있습니다. formatToDateString 유틸리티 함수를 활용하여 날짜 포맷팅을 처리하고, 현재 타임스탬프도 함께 저장하여 시간 정보를 정확히 기록합니다.

---

`31-34`: **세션 데이터 삭제 함수가 명확하게 구현되었습니다.**

세션 관련 데이터를 sessionStorage에서 제거하는 함수가 간결하고 목적에 맞게 잘 구현되었습니다.

</details>
<details>
<summary>src/app/api/log-exit/route.ts (2)</summary>

`17-22`: **필수 필드 검사 로직이 적절합니다**  
`session_id`와 `end_at`가 누락된 경우 400 코드를 반환하는 처리가 유효해 보입니다.

---

`13-14`: <details>
<summary>❓ Verification inconclusive</summary>

**세션 ID 및 종료 시간 유효성 검증을 고려하세요**  
`session_id`와 `end_at`를 그대로 사용하는 대신, 날짜 문자열 혹은 UUID 형식 등으로 검증이 필요한지 확인해 보세요.  

아래 스크립트 등을 통해 다른 곳에서도 입력 형식 검증 로직이 있는지 검색해볼 수 있습니다:

---

🏁 Script executed:

```shell
#!/bin/bash
rg -A 5 "POST(request: Request)"
```

Length of output: 34

---



</details>

**세션 ID 및 종료 시간 검증 로직 추가 요청**

현재 `src/app/api/log-exit/route.ts` 파일의 13-14번째 라인에서 요청 본문으로부터 추출한 `session_id`와 `end_at` 값이 별도의 검증 없이 사용되고 있습니다.  
전체 코드베이스에서 해당 엔드포인트에 대해 별도의 검증 로직(예: UUID 또는 날짜 형식 검증)이 구현되어 있는지 추가 검색을 진행했으나, 관련 증거는 확인되지 않았습니다.

- **요청**: `session_id`와 `end_at`에 대해 올바른 형식(예: UUID, ISO 날짜 형식 등)인지 검증하는 로직을 추가해 주세요.
- **확인 사항**: 혹시 다른 공통 모듈이나 엔드포인트에서 입력값 검증이 수행되고 있다면, 해당 검증 로직과 연계되도록 구현되어 있는지 다시 한번 확인해 주시기 바랍니다.

</details>
<details>
<summary>src/constants/tables.constant.ts (2)</summary>

`6-6`: **새로운 LINKS 테이블 상수 추가 확인**  
링크 정보를 별도 테이블로 분리하여 관리하는 구조는 유지보수와 확장성에 유리합니다.

---

`14-20`: **STORAGE 상수 추가는 용도 구분에 유용합니다**  
여러 스토리지 경로를 분리해둔 것은 가독성과 관리에 도움이 됩니다.

</details>
<details>
<summary>src/components/card/flip-card.tsx (2)</summary>

`14-24`: **컴포넌트 마운트 시점 기록 방식**  
`startedAt` 상태를 `useEffect`에서 Date로 설정하여 세션 추적을 할 수 있는 로직은 간결합니다. 필요하다면 `Date.now()`로 밀리초 단위로 관리하는 것도 좋습니다.

---

`42-47`: **useInteractionTracker 훅 사용**  
slug, source, startedAt 등을 훅에 전달하여 인터랙션 추적을 통합적으로 처리하는 방식이 깔끔합니다.

</details>
<details>
<summary>src/hooks/use-interaction-tracker.ts (2)</summary>

`16-20`: **소스 파라미터의 타입 정의가 명확합니다**  
`'direct' | 'qr' | 'link' | 'tag' | null | undefined` 형태로 관리하여 다양한 유입 경로를 유연하게 처리할 수 있습니다.

---

`55-58`: **세션 초기화 시점 확인**  
`ip`와 `cardId`가 모두 확보된 뒤 세션 초기화를 실행하는 구조가 타당합니다.  

이 로직이 의도대로 움직이는지, 의존성 배열 내 다른 상태와 충돌하지 않는지 확인을 권장드립니다.

</details>
<details>
<summary>src/hooks/mutations/use-init-session.ts (4)</summary>

`1-8`: **적절한 import 구조 확인됨**

필요한 함수들을 외부 모듈에서 잘 가져오고 있습니다. API 함수들과 React Query의 useMutation, 그리고 날짜 포맷 유틸리티가 모두 포함되어 있습니다.

---

`9-12`: **인터페이스 정의가 명확함**

`SessionData` 인터페이스는 세션 ID와 시작 시간을 문자열로 명확하게 정의하고 있습니다.

---

`53-68`: **세션 종료 훅 구현이 간결하고 명확함**

`useEndSessionMutation` 훅이 `endSession` API 함수를 잘 활용하여 간결하게 구현되어 있습니다. 타입 정의와 매개변수 전달이 적절합니다.

---

`118-125`: **명함 이미지 다운로드 훅이 명확하게 구현됨**

`useDownloadCardImageMutation` 훅이 `downloadCardImage` API 함수를 간결하게 사용하고 있습니다.

</details>
<details>
<summary>src/apis/interaction.ts (3)</summary>

`1-4`: **적절한 의존성 import**

상수와 유틸리티 함수 import가 명확하게 정의되어 있습니다.

---

`24-55`: **인터랙션 로깅 함수의 구현이 적절함**

`logInteraction` 함수가 Supabase에 데이터를 삽입하는 로직이 잘 구현되어 있습니다. 오류 처리도 적절히 포함되어 있습니다.

한 가지 고려사항은 `end_at`이 `now`로 설정되고 있는데, 이것이 의도적인지 확인해보세요. 인터랙션 로깅 시점에는 아직 세션이 종료되지 않았을 수 있습니다.


세션 종료 전에 `end_at`을 현재 시간으로 설정하는 것이 의도적인지 확인해보세요. 만약 이 값이 나중에 `endSession` 함수에 의해 업데이트된다면, 설계 의도가 맞는지 검토해야 합니다.

---

`57-82`: <details>
<summary>❓ Verification inconclusive</summary>

**세션 종료 처리 함수의 두 가지 경로 구현이 적절함**

`endSession` 함수가 'page-exit' 상황과 그 외 상황에 대해 다른 처리 방식을 적용하는 것은 좋은 접근 방식입니다. 특히 `navigator.sendBeacon`을 사용하여 페이지가 언로드되는 상황에서도 데이터를 보낼 수 있도록 한 것은 훌륭합니다.

단, '/api/log-exit' API 엔드포인트가 실제로 구현되어 있는지 확인해야 합니다.


---

🏁 Script executed:

```shell
#!/bin/bash
# Description: 'log-exit' API 엔드포인트가 구현되어 있는지 확인

# pages/api 디렉토리에서 log-exit.ts 또는 log-exit.js 파일 찾기
echo "Checking for log-exit API endpoint..."
fd "log-exit\.(ts|js)" pages/api app/api

# app 디렉토리 내에서 route.ts 또는 route.js 파일이 있는 log-exit 디렉토리 찾기
fd "route\.(ts|js)" app/api/log-exit
```

Length of output: 421

---



</details>

**주의: '/api/log-exit' 엔드포인트 구현 위치 재확인 필요**

스크립트 실행 결과, 제공된 검색 경로(예: pages/api, app/api)에서 해당 엔드포인트 관련 파일이 발견되지 않았습니다. 실제로 해당 API 엔드포인트가 구현되어 있다면, 구현 파일이 다른 경로에 존재하는지 또는 누락된 사항은 없는지 직접 확인해 주시기 바랍니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-13T17:17:44Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 2**

<details>
<summary>♻️ Duplicate comments (2)</summary><blockquote>

<details>
<summary>src/apis/interaction.ts (2)</summary><blockquote>

`18-30`: **IP 주소 조회 함수의 오류 처리가 적절히 구현되었습니다**

이전 리뷰에서 지적된 대로 오류 처리가 잘 구현되었습니다. 네트워크 요청 실패 시 적절한 오류 메시지를 제공하고 있습니다.

---

`95-104`: **명함 이미지 다운로드 함수가 적절히 구현되었습니다**

이전 리뷰에서 제안된 대로 `cardId`와 `fileName` 파라미터를 받도록 수정되었습니다. 오류 처리도 적절히 구현되어 있습니다.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between d2766637b5581d15f5dbb019e94bc2ff589ecaa1 and 03105db25dcfc6a087f753d13b86005135ea6d95.

</details>

<details>
<summary>📒 Files selected for processing (4)</summary>

* `src/apis/interaction.ts` (1 hunks)
* `src/hooks/mutations/use-init-session.ts` (1 hunks)
* `src/hooks/use-interaction-tracker.ts` (1 hunks)
* `src/types/supabase.ts` (1 hunks)

</details>

<details>
<summary>✅ Files skipped from review due to trivial changes (1)</summary>

* src/types/supabase.ts

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (2)</summary>

* src/hooks/use-interaction-tracker.ts
* src/hooks/mutations/use-init-session.ts

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>src/apis/interaction.ts (2)</summary><blockquote>

<details>
<summary>src/utils/interaction/format-date.ts (1)</summary>

* `formatToDateString` (7-21)

</details>
<details>
<summary>src/constants/tables.constant.ts (3)</summary>

* `TABLES` (1-7)
* `DB_COLUMNS` (22-68)
* `STORAGE` (14-20)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (1)</summary><blockquote>

<details>
<summary>src/apis/interaction.ts (1)</summary>

`5-13`: **인터페이스가 명확하게 정의되어 있습니다**

코드 리뷰에서 이전에 지적된 `source` 타입의 불일치 문제가 수정되었습니다. 인터페이스가 모든 필요한 파라미터를 잘 정의하고 있습니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-13T18:23:41Z
user: login: woozizi
body: 군더더기 없이 너무 깔끔합니다 LGTTTTTTTTTTMMMMMM@!!!!!!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-13T18:29:14Z
user: login: lyra-j
body: 와~ 코드 분리랑 유틸함수까지!! 
나중에 잘 사용하겠습니다!
고생 많으셨습니다~!!

state: APPROVED
author_association: OWNER
submitted_at: 2025-04-13T18:39:05Z

Review comments:
diff_hunk: @@ -0,0 +1,16 @@
+/**
+ * 주어진 날짜를 yyyy-mm-dd 형식으로 변환하는 함수입니다.
+ *
+ * @param {Date} date - 변환할 Date 객체.
+ * @returns {string} yyyy-mm-dd 형식으로 변환된 날짜 문자열.
+ */
+export const formatToDateString = (date: Date): string => {
+  const year = date.getFullYear();
+  const month = (date.getMonth() + 1).toString().padStart(2, '0');
+  const day = date.getDate().toString().padStart(2, '0');
+
+  const formattedDate = `${year}-${month}-${day}`;
+  const formattedTime = `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
+
+  return formattedDate + ' ' + formattedTime;
+};
path: src/utils/interaction/format-date.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**시간 형식 패딩 개선 필요**

`formatToDateString` 함수는 날짜를 잘 형식화하고 있지만, 시간 부분에서 패딩이 적용되지 않았습니다. 시간, 분, 초가 한 자리 수일 때 앞에 0을 붙여주지 않으면 일관성 없는 형식이 됩니다.


시간 형식도 날짜와 같이 padStart를 사용하여 개선하세요:

```diff
  const formattedDate = `${year}-${month}-${day}`;
- const formattedTime = `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
+ const hours = date.getHours().toString().padStart(2, '0');
+ const minutes = date.getMinutes().toString().padStart(2, '0');
+ const seconds = date.getSeconds().toString().padStart(2, '0');
+ const formattedTime = `${hours}:${minutes}:${seconds}`;
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
export const formatToDateString = (date: Date): string => {
  const year = date.getFullYear();
  const month = (date.getMonth() + 1).toString().padStart(2, '0');
  const day = date.getDate().toString().padStart(2, '0');

  const formattedDate = `${year}-${month}-${day}`;
  const hours = date.getHours().toString().padStart(2, '0');
  const minutes = date.getMinutes().toString().padStart(2, '0');
  const seconds = date.getSeconds().toString().padStart(2, '0');
  const formattedTime = `${hours}:${minutes}:${seconds}`;

  return formattedDate + ' ' + formattedTime;
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 7d13a51
created_at: 2025-04-13T17:17:41Z
author_association: NONE
diff_hunk: @@ -0,0 +1,22 @@
+import { DB_COLUMNS, TABLES } from '@/constants/tables.constant';
+import { createClient } from '@/utils/supabase/client';
+
+/**
+ * 슬러그를 기반으로 명함 ID를 조회
+ *
+ * @param slug 명함 슬러그
+ * @returns 해당 슬러그에 매칭 명함 ID
+ */
+export const getCardId = async (slug: string) => {
+  const supabase = await createClient();
+  const { data, error } = await supabase
+    .from(TABLES.CARDS)
+    .select(DB_COLUMNS.CARDS.ID)
+    .eq(DB_COLUMNS.CARDS.SLUG, slug);
+
+  if (error) {
+    throw error;
+  }
+
+  return data[0].id;
+};
path: src/apis/card-interaction.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**데이터 없음 처리 로직 추가 필요**

`getCardId` 함수에서 데이터가 없을 경우(일치하는 슬러그가 없을 때) 처리가 없습니다. `data[0]`에 접근하면 런타임 오류가 발생할 수 있습니다.


데이터 존재 여부를 확인하는 로직을 추가하세요:

```diff
  if (error) {
    throw error;
  }

+ if (!data || data.length === 0) {
+   throw new Error(`명함을 찾을 수 없습니다: ${slug}`);
+ }

  return data[0].id;
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
export const getCardId = async (slug: string) => {
  const supabase = await createClient();
  const { data, error } = await supabase
    .from(TABLES.CARDS)
    .select(DB_COLUMNS.CARDS.ID)
    .eq(DB_COLUMNS.CARDS.SLUG, slug);

  if (error) {
    throw error;
  }

  if (!data || data.length === 0) {
    throw new Error(`명함을 찾을 수 없습니다: ${slug}`);
  }

  return data[0].id;
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 7d13a51 to d276663
created_at: 2025-04-13T17:17:41Z
author_association: NONE
diff_hunk: @@ -0,0 +1,19 @@
+import { getCardSocialLists } from '@/apis/card-social-list';
+import { QUERY_KEY } from '@/constants/query-key';
+import { useQuery } from '@tanstack/react-query';
+
+/**
+ * 명함의 소셜 링크 목록
+ *
+ * @param card_id 소셜 링크를 조회할 명함 ID
+ * @returns
+ */
+const useCardSocialList = (card_id: string) => {
+  return useQuery({
+    queryKey: [QUERY_KEY.CARD_SOCIAL_LIST],
+    queryFn: async () => getCardSocialLists(card_id),
+    enabled: !!card_id, // card_id가 존재할 때만 쿼리 활성화
+  });
+};
path: src/hooks/queries/use-card-social-list.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**queryKey에 card_id를 포함시켜야 합니다.**

현재 구현에서는 queryKey에 card_id가 포함되어 있지 않아 여러 명함의 데이터가 동일한 캐시를 공유하게 됩니다. 이는 다른 명함을 조회할 때 잘못된 데이터가 표시될 수 있는 문제를 야기할 수 있습니다.


다음과 같이 수정하는 것을 추천합니다:

```diff
return useQuery({
-  queryKey: [QUERY_KEY.CARD_SOCIAL_LIST],
+  queryKey: [QUERY_KEY.CARD_SOCIAL_LIST, card_id],
  queryFn: async () => getCardSocialLists(card_id),
  enabled: !!card_id, // card_id가 존재할 때만 쿼리 활성화
});
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
const useCardSocialList = (card_id: string) => {
  return useQuery({
    queryKey: [QUERY_KEY.CARD_SOCIAL_LIST, card_id],
    queryFn: async () => getCardSocialLists(card_id),
    enabled: !!card_id, // card_id가 존재할 때만 쿼리 활성화
  });
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 7d13a51 to d276663
created_at: 2025-04-13T17:17:41Z
author_association: NONE
diff_hunk: @@ -0,0 +1,19 @@
+import { getCardId } from '@/apis/card-interaction';
+import { QUERY_KEY } from '@/constants/query-key';
+import { useQuery } from '@tanstack/react-query';
+
+/**
+ * 슬러그를 기반으로 명함 ID를 조회하는 쿼리 훅
+ *
+ * @param slug 명함 슬러그 문자열
+ * @returns
+ */
+const useCardInteraction = (slug: string) => {
+  return useQuery({
+    queryKey: [QUERY_KEY.CARD_INTERACTION],
+    queryFn: async () => getCardId(slug),
+    enabled: !!slug, // slug가 존재할 때만 쿼리 활성화
+  });
+};
path: src/hooks/queries/use-card-interaction.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**queryKey에 slug를 포함시켜야 합니다.**

현재 구현에서는 queryKey에 slug가 포함되어 있지 않아 서로 다른 slug에 대한 요청이 동일한 캐시를 공유하게 됩니다. 이는 다른 명함 슬러그로 조회할 때 잘못된 데이터가 표시될 수 있는 문제를 야기할 수 있습니다.


다음과 같이 수정하는 것을 추천합니다:

```diff
return useQuery({
-  queryKey: [QUERY_KEY.CARD_INTERACTION],
+  queryKey: [QUERY_KEY.CARD_INTERACTION, slug],
  queryFn: async () => getCardId(slug),
  enabled: !!slug, // slug가 존재할 때만 쿼리 활성화
});
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
const useCardInteraction = (slug: string) => {
  return useQuery({
    queryKey: [QUERY_KEY.CARD_INTERACTION, slug],
    queryFn: async () => getCardId(slug),
    enabled: !!slug, // slug가 존재할 때만 쿼리 활성화
  });
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 7d13a51 to d276663
created_at: 2025-04-13T17:17:41Z
author_association: NONE
diff_hunk: @@ -39,6 +48,13 @@ export const DB_COLUMNS = {
     FRONT_IMG_URL: 'frontImgURL',
     BACK_IMG_URL: 'backImgURL',
   },
+  LINKS: {
+    ID: 'id',
+    CARD_ID: 'card_id',
+    PLATFOMR: 'platform',
+    URL: 'url',
+    ICON_PATH: 'icon_path',
+  },
path: src/constants/tables.constant.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**'PLATFOMR' 오타를 수정하세요**  
`platform` 키가 맞을 것으로 보입니다. 오타로 인해 코드에서 혼동이 발생할 수 있습니다.  

아래와 같이 수정하는 것을 권장합니다:
```diff
-    PLATFOMR: 'platform',
+    PLATFORM: 'platform',
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
  LINKS: {
    ID: 'id',
    CARD_ID: 'card_id',
    PLATFORM: 'platform',
    URL: 'url',
    ICON_PATH: 'icon_path',
  },
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 7d13a51 to d276663
created_at: 2025-04-13T17:17:42Z
author_association: NONE
diff_hunk: @@ -0,0 +1,125 @@
+import {
+  downloadCardImage,
+  endSession,
+  logInteraction,
+} from '@/apis/interaction';
+import { useMutation } from '@tanstack/react-query';
+import { formatToDateString } from '@/utils/interaction/format-date';
+
+interface SessionData {
+  sessionId: string;
+  startedAt: string;
+}
+
+/**
+ * 세션 초기화
+ *
+ * @param startedAt 세션 시작 시간
+ * @returns
+ */
+export const useInitSessionMutation = (startedAt: Date | null) => {
+  return useMutation({
+    mutationFn: async (): Promise<SessionData> => {
+      const existingSessionId =
+        sessionStorage.getItem('currentSessionId') ||
+        localStorage.getItem('backup_session_id') ||
+        null;
+
+      if (!startedAt) {
+        throw new Error('startedAt is required to initialize a session');
+      }
+
+      if (existingSessionId) {
+        return {
+          sessionId: existingSessionId,
+          startedAt:
+            sessionStorage.getItem('session_started_at') ||
+            formatToDateString(startedAt),
+        };
+      }
+
+      const startTime = formatToDateString(startedAt);
+      const uuid = crypto.randomUUID();
+
+      sessionStorage.setItem('currentSessionId', uuid);
+      sessionStorage.setItem('session_started_at', startTime);
+      localStorage.setItem('backup_session_id', uuid);
+
+      return { sessionId: uuid, startedAt: startTime };
+    },
+  });
+};
+
+/**
+ * 세션 종료를 위한 훅
+ */
+export const useEndSessionMutation = () => {
+  return useMutation({
+    mutationFn: ({
+      reason,
+      sessionId,
+    }: {
+      reason: string;
+      sessionId: string;
+    }) => {
+      return endSession(sessionId, reason);
+    },
+  });
+};
+
+/**
+ * 사용자 인터랙션 로깅
+ *
+ * @param cardId 명함 ID
+ * @param viewerIp 사용자 IP
+ * @param source 접근 출처 (direct, qr, link, tag 등)
+ * @param startedAtDate 시작 시간
+ * @returns
+ */
+export const useLogInteractionMutation = (
+  cardId: string,
+  viewerIp: string | undefined,
+  source: 'direct' | 'qr' | 'link' | 'tag' | null | undefined,
+  startedAtDate: Date | null
+) => {
+  return useMutation({
+    mutationFn: ({
+      elementName,
+      type,
+    }: {
+      elementName: string | null;
+      type: 'click' | 'save' | null | undefined;
+    }) => {
+      if (!viewerIp || !cardId) throw new Error('Missing required data');
+
+      const effectiveSessionId =
+        sessionStorage.getItem('currentSessionId') ||
+        localStorage.getItem('backup_session_id') ||
+        null;
+      if (!startedAtDate) {
+        throw new Error('startedAt is required to initialize a session');
+      }
+
+      if (!effectiveSessionId) throw new Error('No session ID available');
+
+      return logInteraction({
+        cardId,
+        elementName,
+        type,
+        startedAt: formatToDateString(startedAtDate),
+        viewerIp,
+        sessionId: effectiveSessionId,
+        source: source as 'direct' | 'qr' | 'link' | 'iframe' | null,
+      });
+    },
+  });
+};
path: src/hooks/mutations/use-init-session.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**인터랙션 로깅 훅에서 타입 불일치 발견**

인터랙션 로깅 훅 구현에서 한 가지 타입 불일치 문제가 있습니다:

113번 줄에서 `source`를 'direct' | 'qr' | 'link' | 'iframe' | null 타입으로 캐스팅하고 있는데, 이는 82-83번 줄의 매개변수 정의('direct' | 'qr' | 'link' | 'tag' | null | undefined)와 일치하지 않습니다. 'iframe'과 'tag'가 서로 다른 값으로 사용되고 있어 혼란을 줄 수 있습니다.


```diff
export const useLogInteractionMutation = (
  cardId: string,
  viewerIp: string | undefined,
  source: 'direct' | 'qr' | 'link' | 'tag' | null | undefined,
  startedAtDate: Date | null
) => {
  return useMutation({
    mutationFn: ({
      elementName,
      type,
    }: {
      elementName: string | null;
      type: 'click' | 'save' | null | undefined;
    }) => {
      if (!viewerIp || !cardId) throw new Error('Missing required data');

      const effectiveSessionId =
        sessionStorage.getItem('currentSessionId') ||
        localStorage.getItem('backup_session_id') ||
        null;
      if (!startedAtDate) {
        throw new Error('startedAt is required to initialize a session');
      }

      if (!effectiveSessionId) throw new Error('No session ID available');

      return logInteraction({
        cardId,
        elementName,
        type,
        startedAt: formatToDateString(startedAtDate),
        viewerIp,
-       sessionId: effectiveSessionId,
-       source: source as 'direct' | 'qr' | 'link' | 'iframe' | null,
+       sessionId: effectiveSessionId,
+       source: source as 'direct' | 'qr' | 'link' | 'tag' | null,
      });
    },
  });
};
```

또한 `LogInteractionParams` 인터페이스(src/apis/interaction.ts 파일)에서도 'iframe' 대신 'tag'로 일관되게 수정해야 합니다.


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
/**
 * 사용자 인터랙션 로깅
 *
 * @param cardId 명함 ID
 * @param viewerIp 사용자 IP
 * @param source 접근 출처 (direct, qr, link, tag 등)
 * @param startedAtDate 시작 시간
 * @returns
 */
export const useLogInteractionMutation = (
  cardId: string,
  viewerIp: string | undefined,
  source: 'direct' | 'qr' | 'link' | 'tag' | null | undefined,
  startedAtDate: Date | null
) => {
  return useMutation({
    mutationFn: ({
      elementName,
      type,
    }: {
      elementName: string | null;
      type: 'click' | 'save' | null | undefined;
    }) => {
      if (!viewerIp || !cardId) throw new Error('Missing required data');

      const effectiveSessionId =
        sessionStorage.getItem('currentSessionId') ||
        localStorage.getItem('backup_session_id') ||
        null;
      if (!startedAtDate) {
        throw new Error('startedAt is required to initialize a session');
      }

      if (!effectiveSessionId) throw new Error('No session ID available');

      return logInteraction({
        cardId,
        elementName,
        type,
        startedAt: formatToDateString(startedAtDate),
        viewerIp,
        sessionId: effectiveSessionId,
        source: source as 'direct' | 'qr' | 'link' | 'tag' | null,
      });
    },
  });
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 8e955ee to 7dbc4b6
created_at: 2025-04-13T17:17:42Z
author_association: NONE
diff_hunk: @@ -0,0 +1,97 @@
+import { DB_COLUMNS, STORAGE, TABLES } from '@/constants/tables.constant';
+import { formatToDateString } from '@/utils/interaction/format-date';
+import { createClient } from '@/utils/supabase/client';
+
+interface LogInteractionParams {
+  cardId: string;
+  elementName: string | null;
+  type: 'click' | 'save' | null | undefined;
+  startedAt: string;
+  viewerIp: string;
+  sessionId: string;
+  source: 'direct' | 'qr' | 'link' | 'iframe' | null;
+}
path: src/apis/interaction.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**LogInteractionParams 인터페이스 타입 불일치**

`LogInteractionParams` 인터페이스에서 `source` 타입이 'iframe'을 포함하고 있는데, 이는 `useLogInteractionMutation` 훅에서 정의된 'tag'와 일치하지 않습니다. 이 불일치는 런타임 오류를 야기할 수 있습니다.


```diff
interface LogInteractionParams {
  cardId: string;
  elementName: string | null;
  type: 'click' | 'save' | null | undefined;
  startedAt: string;
  viewerIp: string;
  sessionId: string;
-  source: 'direct' | 'qr' | 'link' | 'iframe' | null;
+  source: 'direct' | 'qr' | 'link' | 'tag' | null;
}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
interface LogInteractionParams {
  cardId: string;
  elementName: string | null;
  type: 'click' | 'save' | null | undefined;
  startedAt: string;
  viewerIp: string;
  sessionId: string;
  source: 'direct' | 'qr' | 'link' | 'tag' | null;
}
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commit 8e955ee
created_at: 2025-04-13T17:17:42Z
author_association: NONE
diff_hunk: @@ -0,0 +1,97 @@
+import { DB_COLUMNS, STORAGE, TABLES } from '@/constants/tables.constant';
+import { formatToDateString } from '@/utils/interaction/format-date';
+import { createClient } from '@/utils/supabase/client';
+
+interface LogInteractionParams {
+  cardId: string;
+  elementName: string | null;
+  type: 'click' | 'save' | null | undefined;
+  startedAt: string;
+  viewerIp: string;
+  sessionId: string;
+  source: 'direct' | 'qr' | 'link' | 'iframe' | null;
+}
+
+/**
+ * 사용자 IP 주소 조회
+ */
+export const getIpAddress = async (): Promise<string> => {
+  const res = await fetch('https://api.ipify.org?format=json');
+  const data = await res.json();
+  return data.ip;
+};
path: src/apis/interaction.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**IP 주소 조회 함수의 오류 처리 부재**

`getIpAddress` 함수에서 네트워크 요청 실패에 대한 오류 처리가 누락되어 있습니다. 외부 API 호출 시에는 항상 적절한 오류 처리가 필요합니다.


```diff
export const getIpAddress = async (): Promise<string> => {
-  const res = await fetch('https://api.ipify.org?format=json');
-  const data = await res.json();
-  return data.ip;
+  try {
+    const res = await fetch('https://api.ipify.org?format=json');
+    if (!res.ok) {
+      throw new Error(`IP 주소 조회 실패: ${res.status} ${res.statusText}`);
+    }
+    const data = await res.json();
+    return data.ip;
+  } catch (error) {
+    console.error('IP 주소 조회 중 오류 발생:', error);
+    throw new Error('IP 주소를 가져오는 데 실패했습니다.');
+  }
};
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
/**
 * 사용자 IP 주소 조회
 */
export const getIpAddress = async (): Promise<string> => {
  try {
    const res = await fetch('https://api.ipify.org?format=json');
    if (!res.ok) {
      throw new Error(`IP 주소 조회 실패: ${res.status} ${res.statusText}`);
    }
    const data = await res.json();
    return data.ip;
  } catch (error) {
    console.error('IP 주소 조회 중 오류 발생:', error);
    throw new Error('IP 주소를 가져오는 데 실패했습니다.');
  }
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 8e955ee to 6c9e809
created_at: 2025-04-13T17:17:42Z
author_association: NONE
diff_hunk: @@ -0,0 +1,97 @@
+import { DB_COLUMNS, STORAGE, TABLES } from '@/constants/tables.constant';
+import { formatToDateString } from '@/utils/interaction/format-date';
+import { createClient } from '@/utils/supabase/client';
+
+interface LogInteractionParams {
+  cardId: string;
+  elementName: string | null;
+  type: 'click' | 'save' | null | undefined;
+  startedAt: string;
+  viewerIp: string;
+  sessionId: string;
+  source: 'direct' | 'qr' | 'link' | 'iframe' | null;
+}
+
+/**
+ * 사용자 IP 주소 조회
+ */
+export const getIpAddress = async (): Promise<string> => {
+  const res = await fetch('https://api.ipify.org?format=json');
+  const data = await res.json();
+  return data.ip;
+};
+
+/**
+ * 사용자 인터랙션 로깅
+ */
+export const logInteraction = async ({
+  cardId,
+  elementName,
+  type,
+  startedAt,
+  viewerIp,
+  sessionId,
+  source,
+}: LogInteractionParams) => {
+  const now = formatToDateString(new Date());
+
+  const supabase = await createClient();
+
+  const { data, error } = await supabase.from(TABLES.CARD_VIEWS).insert({
+    card_id: cardId,
+    element_name: elementName,
+    occurred_at: now,
+    started_at: startedAt,
+    end_at: now,
+    source: source,
+    type: type,
+    viewer_id: null,
+    viewer_ip: viewerIp,
+    session_id: sessionId,
+  });
+
+  if (error) throw error;
+  return data;
+};
+
+/**
+ * 세션 종료 처리
+ */
+export const endSession = async (sessionId: string, reason: string) => {
+  const now = formatToDateString(new Date());
+  const supabase = await createClient();
+
+  if (reason === 'page-exit') {
+    navigator.sendBeacon(
+      '/api/log-exit',
+      JSON.stringify({
+        session_id: sessionId,
+        end_at: now,
+      })
+    );
+    return { success: true };
+  } else {
+    const { error } = await supabase
+      .from(TABLES.CARD_VIEWS)
+      .update({ end_at: now })
+      .eq(DB_COLUMNS.CARD_VIEWS.SESSION_ID, sessionId);
+
+    if (error) throw error;
+    return { success: true };
+  }
+};
+
+/**
+ * 명함 이미지 다운로드
+ */
+export const downloadCardImage = async () => {
+  // dummy data 사용 (추후 변경 예정)
+  const supabase = await createClient();
+  const { data, error } = await supabase.storage
+    .from(STORAGE.CARDS)
+    .download('card_test.jpg');
+
+  if (error) throw error;
+
+  return data;
+};
path: src/apis/interaction.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**명함 이미지 다운로드 함수의 테스트 코드 표시**

`downloadCardImage` 함수가 더미 데이터를 사용하고 있으며 추후 변경 예정이라는 주석이 있습니다. 이는 적절한 표시이지만, 실제 구현 시에는 동적 파라미터를 받아 특정 카드 이미지를 다운로드할 수 있도록 수정해야 합니다.


```diff
/**
 * 명함 이미지 다운로드
 */
-export const downloadCardImage = async () => {
+export const downloadCardImage = async (cardId: string, fileName: string) => {
  // dummy data 사용 (추후 변경 예정)
  const supabase = await createClient();
  const { data, error } = await supabase.storage
    .from(STORAGE.CARDS)
-    .download('card_test.jpg');
+    .download(`${cardId}/${fileName}`);

  if (error) throw error;

  return data;
};
```

이에 따라 `useDownloadCardImageMutation` 훅도 파라미터를 받도록 수정해야 합니다.


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
/**
 * 명함 이미지 다운로드
 */
export const downloadCardImage = async (cardId: string, fileName: string) => {
  // dummy data 사용 (추후 변경 예정)
  const supabase = await createClient();
  const { data, error } = await supabase.storage
    .from(STORAGE.CARDS)
    .download(`${cardId}/${fileName}`);

  if (error) throw error;

  return data;
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 8e955ee to 03105db
created_at: 2025-04-13T17:17:42Z
author_association: NONE
diff_hunk: @@ -0,0 +1,104 @@
+import { DB_COLUMNS, STORAGE, TABLES } from '@/constants/tables.constant';
+import { formatToDateString } from '@/utils/interaction/format-date';
+import { createClient } from '@/utils/supabase/client';
+
+interface LogInteractionParams {
+  cardId: string;
+  elementName: string | null;
+  type: 'click' | 'save' | null | undefined;
+  startedAt: string;
+  viewerIp: string;
+  sessionId: string;
+  source: 'direct' | 'qr' | 'link' | 'tag' | null;
+}
+
+/**
+ * 사용자 IP 주소 조회
+ */
+export const getIpAddress = async (): Promise<string> => {
+  try {
+    const res = await fetch('https://api.ipify.org?format=json');
+    if (!res.ok) {
+      throw new Error(`IP 주소 조회 실패: ${res.status} ${res.statusText}`);
+    }
+    const data = await res.json();
+    return data.ip;
+  } catch (error) {
+    console.error('IP 주소 조회 중 오류 발생:', error);
+    throw new Error('IP 주소를 가져오는 데 실패했습니다.');
+  }
+};
+
+/**
+ * 사용자 인터랙션 로깅
+ */
+export const logInteraction = async ({
+  cardId,
+  elementName,
+  type,
+  startedAt,
+  viewerIp,
+  sessionId,
+  source,
+}: LogInteractionParams) => {
+  const now = formatToDateString(new Date());
+
+  const supabase = await createClient();
+
+  const { data, error } = await supabase.from(TABLES.CARD_VIEWS).insert({
+    card_id: cardId,
+    element_name: elementName,
+    occurred_at: now,
+    started_at: startedAt,
+    end_at: now,
+    source: source,
+    type: type,
+    viewer_id: null,
+    viewer_ip: viewerIp,
+    session_id: sessionId,
+  });
+
+  if (error) throw error;
+  return data;
+};
+
+/**
+ * 세션 종료 처리
+ */
+export const endSession = async (sessionId: string, reason: string) => {
+  const now = formatToDateString(new Date());
+  const supabase = await createClient();
+
+  if (reason === 'page-exit') {
+    navigator.sendBeacon(
+      '/api/log-exit',
+      JSON.stringify({
+        session_id: sessionId,
+        end_at: now,
+      })
+    );
+    return { success: true };
+  } else {
+    const { error } = await supabase
+      .from(TABLES.CARD_VIEWS)
+      .update({ end_at: now })
+      .eq(DB_COLUMNS.CARD_VIEWS.SESSION_ID, sessionId);
+
+    if (error) throw error;
+    return { success: true };
+  }
+};
path: src/apis/interaction.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**세션 종료 처리 함수의 개선 사항**

1. `reason` 파라미터에 대한 유효성 검사가 부족합니다. 허용된 값('page-exit' 또는 다른 값)에 대한 명시적인 검사나 타입 정의가 필요합니다.

2. `sendBeacon`은 비동기적으로 실행되지만 오류 처리 메커니즘이 없습니다. 비콘 요청이 실패할 경우에 대한 대체 처리 방안을 고려해 보세요.

3. '/api/log-exit' API 엔드포인트가 존재하는지, 그리고 전송된 데이터를 적절히 처리하는지 확인이 필요합니다.


```diff
export const endSession = async (sessionId: string, reason: string) => {
+  // 유효한 reason 값 검증
+  const validReasons = ['page-exit', 'timeout', 'user-action'];
+  if (!validReasons.includes(reason)) {
+    console.warn(`유효하지 않은 세션 종료 이유: ${reason}`);
+  }

  const now = formatToDateString(new Date());
  const supabase = await createClient();

  if (reason === 'page-exit') {
-    navigator.sendBeacon(
-      '/api/log-exit',
-      JSON.stringify({
-        session_id: sessionId,
-        end_at: now,
-      })
-    );
-    return { success: true };
+    const beaconSent = navigator.sendBeacon(
+      '/api/log-exit',
+      JSON.stringify({
+        session_id: sessionId,
+        end_at: now,
+      })
+    );
+    
+    if (beaconSent) {
+      return { success: true };
+    } else {
+      // sendBeacon이 실패한 경우 대체 방법으로 직접 업데이트
+      try {
+        const { error } = await supabase
+          .from(TABLES.CARD_VIEWS)
+          .update({ end_at: now })
+          .eq(DB_COLUMNS.CARD_VIEWS.SESSION_ID, sessionId);
+        
+        if (error) throw error;
+        return { success: true };
+      } catch (error) {
+        console.error('세션 종료 처리 실패:', error);
+        return { success: false, error };
+      }
+    }
  } else {
    const { error } = await supabase
      .from(TABLES.CARD_VIEWS)
      .update({ end_at: now })
      .eq(DB_COLUMNS.CARD_VIEWS.SESSION_ID, sessionId);

    if (error) throw error;
    return { success: true };
  }
};
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
export const endSession = async (sessionId: string, reason: string) => {
  // 유효한 reason 값 검증
  const validReasons = ['page-exit', 'timeout', 'user-action'];
  if (!validReasons.includes(reason)) {
    console.warn(`유효하지 않은 세션 종료 이유: ${reason}`);
  }
  
  const now = formatToDateString(new Date());
  const supabase = await createClient();

  if (reason === 'page-exit') {
    const beaconSent = navigator.sendBeacon(
      '/api/log-exit',
      JSON.stringify({
        session_id: sessionId,
        end_at: now,
      })
    );
    
    if (beaconSent) {
      return { success: true };
    } else {
      // sendBeacon이 실패한 경우 대체 방법으로 직접 업데이트
      try {
        const { error } = await supabase
          .from(TABLES.CARD_VIEWS)
          .update({ end_at: now })
          .eq(DB_COLUMNS.CARD_VIEWS.SESSION_ID, sessionId);
        
        if (error) throw error;
        return { success: true };
      } catch (error) {
        console.error('세션 종료 처리 실패:', error);
        return { success: false, error };
      }
    }
  } else {
    const { error } = await supabase
      .from(TABLES.CARD_VIEWS)
      .update({ end_at: now })
      .eq(DB_COLUMNS.CARD_VIEWS.SESSION_ID, sessionId);

    if (error) throw error;
    return { success: true };
  }
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T18:23:37Z
author_association: NONE
diff_hunk: @@ -0,0 +1,104 @@
+import { DB_COLUMNS, STORAGE, TABLES } from '@/constants/tables.constant';
+import { formatToDateString } from '@/utils/interaction/format-date';
+import { createClient } from '@/utils/supabase/client';
+
+interface LogInteractionParams {
+  cardId: string;
+  elementName: string | null;
+  type: 'click' | 'save' | null | undefined;
+  startedAt: string;
+  viewerIp: string;
+  sessionId: string;
+  source: 'direct' | 'qr' | 'link' | 'tag' | null;
+}
+
+/**
+ * 사용자 IP 주소 조회
+ */
+export const getIpAddress = async (): Promise<string> => {
+  try {
+    const res = await fetch('https://api.ipify.org?format=json');
+    if (!res.ok) {
+      throw new Error(`IP 주소 조회 실패: ${res.status} ${res.statusText}`);
+    }
+    const data = await res.json();
+    return data.ip;
+  } catch (error) {
+    console.error('IP 주소 조회 중 오류 발생:', error);
+    throw new Error('IP 주소를 가져오는 데 실패했습니다.');
+  }
+};
+
+/**
+ * 사용자 인터랙션 로깅
+ */
+export const logInteraction = async ({
+  cardId,
+  elementName,
+  type,
+  startedAt,
+  viewerIp,
+  sessionId,
+  source,
+}: LogInteractionParams) => {
+  const now = formatToDateString(new Date());
+
+  const supabase = await createClient();
+
+  const { data, error } = await supabase.from(TABLES.CARD_VIEWS).insert({
+    card_id: cardId,
+    element_name: elementName,
+    occurred_at: now,
+    started_at: startedAt,
+    end_at: now,
+    source: source,
+    type: type,
+    viewer_id: null,
+    viewer_ip: viewerIp,
+    session_id: sessionId,
+  });
+
+  if (error) throw error;
+  return data;
+};
path: src/apis/interaction.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**logInteraction 함수에서 end_at 필드 설정 검토 필요**

현재 `end_at` 필드가 `occurred_at`과 동일한 시간(현재 시간)으로 설정되어 있습니다. 그러나 인터랙션을 로깅하는 시점에서는 세션이 아직 진행 중일 가능성이 높습니다. 실제 세션 종료 시 `endSession` 함수에서 이 값을 업데이트하는 것으로 보이므로, 초기 로깅 시에는 `end_at` 필드를 `null`로 설정하거나 생략하는 것이 더 적절할 수 있습니다.


```diff
const { data, error } = await supabase.from(TABLES.CARD_VIEWS).insert({
  card_id: cardId,
  element_name: elementName,
  occurred_at: now,
  started_at: startedAt,
-  end_at: now,
  source: source,
  type: type,
  viewer_id: null,
  viewer_ip: viewerIp,
  session_id: sessionId,
});
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
export const logInteraction = async ({
  cardId,
  elementName,
  type,
  startedAt,
  viewerIp,
  sessionId,
  source,
}: LogInteractionParams) => {
  const now = formatToDateString(new Date());

  const supabase = await createClient();

  const { data, error } = await supabase.from(TABLES.CARD_VIEWS).insert({
    card_id: cardId,
    element_name: elementName,
    occurred_at: now,
    started_at: startedAt,
    source: source,
    type: type,
    viewer_id: null,
    viewer_ip: viewerIp,
    session_id: sessionId,
  });

  if (error) throw error;
  return data;
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-13T18:23:37Z
author_association: NONE

PR comments:
user: login: vercel[bot]
created_at: 2025-04-13T17:12:54Z
body: [vc]: #aYUV7ULYI4rowmR5fhkQXcWXXr+PGv4LAZWbSnVrMQo=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ1dW5vIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6InV1bm8tZ2l0LWZlYXQtNzQtaW50ZXJhY3Rpb24taGl0bWFwLWx5cmEtanMtcHJvamVjdHMudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vbHlyYS1qcy1wcm9qZWN0cy91dW5vLzhkTFR0bWt0N0tzZERwQXAzWEMxSDg1UEpnV2YiLCJwcmV2aWV3VXJsIjoidXVuby1naXQtZmVhdC03NC1pbnRlcmFjdGlvbi1oaXRtYXAtbHlyYS1qcy1wcm9qZWN0cy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwicm9vdERpcmVjdG9yeSI6bnVsbH1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **uuno** | ✅ Ready ([Inspect](https://vercel.com/lyra-js-projects/uuno/8dLTtmkt7KsdDpAp3XC1H85PJgWf)) | [Visit Preview](https://uuno-git-feat-74-interaction-hitmap-lyra-js-projects.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/uuno-git-feat-74-interaction-hitmap-lyra-js-projects.vercel.app?via=pr-comment-feedback-link) | Apr 13, 2025 6:20pm |


author_association: NONE
user: login: coderabbitai[bot]
created_at: 2025-04-13T17:12:56Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough
이번 PR은 Supabase를 활용하여 카드 관련 데이터를 조회하고, 유저 인터랙션 및 세션 관리를 수행하는 여러 비동기 함수와 커스텀 훅을 추가합니다. 또한, API 엔드포인트와 상수, 유틸 함수들도 도입되어 카드의 소셜 링크, IP 주소 조회, 인터랙션 로깅, 세션 종료 등의 기능을 지원합니다. FlipCard 컴포넌트의 인터랙션 및 URL 파라미터 처리 로직도 개선되었습니다.

## Changes

| 파일(경로) | 변경 사항 요약 |
| --- | --- |
| `src/apis/card-interaction.ts`<br>`src/apis/card-social-list.ts` | 카드 조회 및 소셜 링크 조회를 위한 각각의 비동기 함수(`getCardId`, `getCardSocialLists`) 추가 |
| `src/apis/interaction.ts` | 유저 IP 조회, 인터랙션 로깅, 세션 종료, 카드 이미지 다운로드 기능 추가 |
| `src/app/api/log-exit/route.ts` | 세션 종료 로그 업데이트를 위한 POST API 엔드포인트 추가 |
| `src/components/card/flip-card.tsx` | URL 파라미터 파싱, 인터랙션 추적 훅(`useInteractionTracker`) 연동 및 소셜 링크 버튼 동적 렌더링 추가 |
| `src/constants/query-key.ts` | 신규 상수(`CARD_INTERACTION`, `USER_IP`, `CARD_SOCIAL_LIST`) 추가 |
| `src/constants/tables.constant.ts` | 신규 테이블 및 컬럼(`LINKS`) 추가, 저장소(STORAGE) 관련 상수 도입 |
| `src/hooks/mutations/use-init-session.ts` | 세션 초기화, 종료, 인터랙션 로깅을 위한 커스텀 훅 추가 |
| `src/hooks/queries/use-card-interaction.ts`<br>`src/hooks/queries/use-card-social-list.ts`<br>`src/hooks/queries/use-ip-address.ts` | 각각 카드 조회, 소셜 링크 조회, IP 주소 조회용 커스텀 React 훅 추가 |
| `src/hooks/use-interaction-tracker.ts` | 전반적인 인터랙션 추적 및 세션 관리를 위한 커스텀 훅 도입 |
| `src/utils/interaction/format-date.ts`<br>`src/utils/interaction/session-util.ts` | 인터랙션 날짜 포맷 및 세션 관련 유틸 함수(`formatToDateString`, `getEffectiveSessionId`, `storePendingSessionEnd`, `clearSessionData`) 추가 |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant U as 사용자
    participant FC as FlipCard 컴포넌트
    participant UIT as useInteractionTracker
    participant API as Supabase/DB

    U->>FC: 소셜 링크 버튼 클릭
    FC->>UIT: handleClick 호출 (URL, 플랫폼)
    UIT->>API: logInteraction 함수 호출
    API-->>UIT: 응답 (성공/오류)
```

```mermaid
sequenceDiagram
    participant FC as FlipCard 컴포넌트
    participant UIT as useInteractionTracker
    participant SIS as useInitSessionMutation
    participant API as Supabase/DB

    FC->>UIT: mount 시 slug, source, startedAt 전달
    UIT->>SIS: 세션 초기화 요청 (IP 및 카드 ID 확인)
    SIS->>API: 필요 시 세션 생성 요청
    API-->>SIS: 세션 ID 응답
    SIS-->>UIT: 세션 초기화 완료
    Note right of UIT: 비활동 시 세션 종료 처리
```

## Assessment against linked issues

| Objective (이슈 번호)                   | Addressed | Explanation               |
| --------------------------------------- | --------- | ------------------------- |
| 인터렉션 히트맵 (#74)                   | ✅        |                           |
| 공유된 페이지 명함 렌더링 (#74)         | ✅        | FlipCard 컴포넌트 업데이트로 반영됨 |

## Poem
> 귀여운 토끼가 뛰노는 코드 숲  
> 새로 생긴 기능으로 발랄하게  
> 로그와 세션이 춤추듯 기록되고  
> 소셜 링크도 반짝반짝 빛나네  
> 오늘도 신나게 달리는 우리 코드, 윙크! 🐇💻

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=lyra-j/uuno&utm_content=86):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE