From fa63c83e6510f50bdb6360e41032bb48508d3c89 Mon Sep 17 00:00:00 2001 From: dasosann Date: Tue, 19 May 2026 10:18:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20qa=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20p?= =?UTF-8?q?wq=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/_components/AdminMainScreen.tsx | 18 +- .../users/_components/AdminMembers.tsx | 705 ++++++++++++++++++ app/adminpage/users/page.tsx | 28 + app/chat-list/_components/ScreenChatList.tsx | 17 +- .../_components/PartnerProfileModal.tsx | 36 +- app/firebase-messaging-sw.js/route.ts | 6 +- app/layout.tsx | 8 + app/manifest.ts | 27 + .../_components/NoMatchingList.tsx | 42 +- .../_components/Step4ContactFrequency.tsx | 2 +- components/common/PwaInitializer.tsx | 27 + components/common/ToastContainer.tsx | 7 +- components/common/charge/QuickBundleCard.tsx | 6 +- components/common/charge/ShopRows.tsx | 4 +- hooks/admin/useAdminMembers.ts | 112 +++ lib/firebase.ts | 10 +- public/sw.js | 52 ++ 17 files changed, 1049 insertions(+), 58 deletions(-) create mode 100644 app/adminpage/users/_components/AdminMembers.tsx create mode 100644 app/adminpage/users/page.tsx create mode 100644 app/manifest.ts create mode 100644 components/common/PwaInitializer.tsx create mode 100644 hooks/admin/useAdminMembers.ts create mode 100644 public/sw.js diff --git a/app/adminpage/main/_components/AdminMainScreen.tsx b/app/adminpage/main/_components/AdminMainScreen.tsx index d7f40f1..b2d3875 100644 --- a/app/adminpage/main/_components/AdminMainScreen.tsx +++ b/app/adminpage/main/_components/AdminMainScreen.tsx @@ -24,6 +24,15 @@ const MENU_ITEMS = [ gradient: "from-[#ff4d61] to-[#ff775e]", badge: "LIVE", }, + { + id: "users", + title: "사용자 관리", + description: "가입된 사용자 목록 조회 및 관리", + icon: Users, + href: "/adminpage/users", + active: true, + gradient: "from-[#06b6d4] to-[#3b82f6]", + }, { id: "products", title: "상품 관리", @@ -42,15 +51,6 @@ const MENU_ITEMS = [ active: true, gradient: "from-[#10b981] to-[#059669]", }, - { - id: "users", - title: "사용자 관리", - description: "가입된 사용자 목록 조회 및 관리", - icon: Users, - href: "#", - active: false, - gradient: "from-[#06b6d4] to-[#3b82f6]", - }, { id: "stats", title: "통계", diff --git a/app/adminpage/users/_components/AdminMembers.tsx b/app/adminpage/users/_components/AdminMembers.tsx new file mode 100644 index 0000000..1ac8898 --- /dev/null +++ b/app/adminpage/users/_components/AdminMembers.tsx @@ -0,0 +1,705 @@ +"use client"; + +import React, { useState } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { + useAdminMembers, + useAdjustMemberItems, + AdminMember, +} from "@/hooks/admin/useAdminMembers"; +import { useToastStore } from "@/stores/toast-store"; +import { AxiosError } from "axios"; +import { + ArrowLeft, + Users, + Loader2, + Search, + Inbox, + X, + User, + Plus, + Minus, + Edit3, + AlertTriangle, +} from "lucide-react"; + +export default function AdminMembers() { + const [searchInput, setSearchInput] = useState(""); + const [searchKeyword, setSearchKeyword] = useState(""); + const [editingMember, setEditingMember] = useState(null); + + // 아이템 조절 모달 폼 상태 (동시 수정 지원) + const [matchingAction, setMatchingAction] = useState< + "ADD" | "REMOVE" | "NONE" + >("NONE"); + const [matchingQuantity, setMatchingQuantity] = useState(1); + const [optionAction, setOptionAction] = useState<"ADD" | "REMOVE" | "NONE">( + "NONE", + ); + const [optionQuantity, setOptionQuantity] = useState(1); + const [adjustReason, setAdjustReason] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { showToast } = useToastStore(); + + const { + data: membersData, + isLoading, + isRefetching, + } = useAdminMembers(searchKeyword || undefined); + + const adjustMutation = useAdjustMemberItems(); + + const members = membersData?.data ?? []; + + // 검색 실행 + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setSearchKeyword(searchInput.trim()); + }; + + // 검색 초기화 + const handleClearSearch = () => { + setSearchInput(""); + setSearchKeyword(""); + }; + + // 조정 모달 열기 + const openEditModal = (member: AdminMember) => { + setEditingMember(member); + setMatchingAction("NONE"); + setMatchingQuantity(1); + setOptionAction("NONE"); + setOptionQuantity(1); + setAdjustReason(""); + }; + + // 조정 모달 닫기 + const closeEditModal = () => { + setEditingMember(null); + }; + + // 실제 조정 API 요청 (동시 수정 지원) + const handleSave = async () => { + if (!editingMember) return; + if (matchingAction === "NONE" && optionAction === "NONE") { + showToast({ + title: "입력 오류", + body: "조정할 아이템 수량을 최소 하나 이상 설정해주세요.", + icon: "error", + }); + return; + } + if (!adjustReason.trim()) { + showToast({ + title: "입력 오류", + body: "조정 사유를 입력해주세요.", + icon: "error", + }); + return; + } + + setIsSubmitting(true); + const promises: Promise[] = []; + + if (matchingAction !== "NONE") { + promises.push( + adjustMutation.mutateAsync({ + memberId: editingMember.id, + body: { + itemType: "MATCHING_TICKET", + quantity: matchingQuantity, + action: matchingAction, + reason: adjustReason.trim(), + }, + }), + ); + } + + if (optionAction !== "NONE") { + promises.push( + adjustMutation.mutateAsync({ + memberId: editingMember.id, + body: { + itemType: "OPTION_TICKET", + quantity: optionQuantity, + action: optionAction, + reason: adjustReason.trim(), + }, + }), + ); + } + + try { + await Promise.all(promises); + showToast({ + title: "조정 완료", + body: `${editingMember.nickname}님의 아이템이 정상적으로 조정되었습니다.`, + icon: "success", + }); + closeEditModal(); + } catch (error) { + const axiosError = error as AxiosError<{ + code?: string; + message?: string; + }>; + const errorCode = axiosError.response?.data?.code; + const errorMessage = axiosError.response?.data?.message; + + let bodyMsg = "아이템 수량을 조정하는 중 에러가 발생했습니다."; + if (errorCode === "ITEM-006") { + bodyMsg = + "동일한 내용의 조정 요청이 이미 처리 중입니다. (3초 중복 방지)"; + } else if (errorCode === "ITEM-001") { + bodyMsg = "보유 수량이 부족하여 차감할 수 없습니다."; + } else if (errorMessage) { + bodyMsg = errorMessage; + } + + showToast({ + title: "조정 실패", + body: bodyMsg, + icon: "error", + }); + } finally { + setIsSubmitting(false); + } + }; + + // 실시간 예상 결과 수량 계산 + const getExpectedQuantity = ( + itemType: "MATCHING_TICKET" | "OPTION_TICKET", + ) => { + if (!editingMember) return 0; + const current = + itemType === "MATCHING_TICKET" + ? editingMember.matchingTicketCount + : editingMember.optionTicketCount; + const action = + itemType === "MATCHING_TICKET" ? matchingAction : optionAction; + const quantity = + itemType === "MATCHING_TICKET" ? matchingQuantity : optionQuantity; + + if (action === "ADD") { + return current + quantity; + } else if (action === "REMOVE") { + return Math.max(0, current - quantity); + } + return current; + }; + + // 차감 가능 여부 체크 + const isInsufficient = (itemType: "MATCHING_TICKET" | "OPTION_TICKET") => { + if (!editingMember) return false; + const action = + itemType === "MATCHING_TICKET" ? matchingAction : optionAction; + const quantity = + itemType === "MATCHING_TICKET" ? matchingQuantity : optionQuantity; + if (action !== "REMOVE") return false; + + const current = + itemType === "MATCHING_TICKET" + ? editingMember.matchingTicketCount + : editingMember.optionTicketCount; + return current < quantity; + }; + + return ( +
+ {/* 헤더 */} +
+
+ + + +
+
+ +
+
+

+ 사용자 관리 +

+

+ {searchKeyword ? `'${searchKeyword}' 검색 결과 ` : "전체 "}{" "} + {members.length}명 사용자 +

+
+
+
+
+ + {/* 검색 바 */} +
+
+ + setSearchInput(e.target.value)} + className="w-full rounded-xl border border-[#1e2030] bg-[#161827] py-2.5 pr-10 pl-10 text-sm text-white placeholder-[#4a4e69] transition-all duration-200 focus:border-[#06b6d4]/50 focus:bg-[#161827] focus:outline-none" + /> + {searchInput && ( + + )} +
+ +
+ + {/* 사용자 목록 */} + {isLoading ? ( +
+ +

+ 사용자 목록 불러오는 중... +

+
+ ) : members.length === 0 ? ( +
+
+ +
+

+ {searchKeyword + ? "검색 결과에 맞는 사용자가 없습니다" + : "가입된 사용자가 없습니다"} +

+

+ {searchKeyword + ? "다른 검색어로 다시 시도해보세요" + : "사용자가 가입하면 목록에 나타납니다"} +

+
+ ) : ( +
+ {members.map((member) => ( +
+ {/* 사용자 기본 정보 */} +
+
+ +
+
+
+ + {member.nickname} + + + {member.gender === "FEMALE" ? "여성" : "남성"} + +
+ + {member.email} + +
+
+ + {/* 보유 아이템 정보 및 수정 버튼 */} +
+
+ {/* 매칭권 */} +
+ 매칭권 +
+ 매칭권 + + {member.matchingTicketCount}개 + +
+
+ {/* 옵션권 */} +
+ 옵션권 +
+ 옵션권 + + {member.optionTicketCount}개 + +
+
+
+ + +
+
+ ))} +
+ )} + + {/* 수량 조정 상세 모달 */} + {editingMember && ( +
+
+ {/* 모달 헤더 */} +
+

+ 아이템 수량 조정 +

+ +
+ + {/* 모달 대상 사용자 정보 */} +
+
+ +
+
+
+ + {editingMember.nickname} + + + {editingMember.gender === "FEMALE" ? "여성" : "남성"} + +
+ + {editingMember.email} + +
+
+ + {/* 아이템 설정 필드들 */} +
+ {/* ─── 1. 매칭권 설정 ─── */} +
+
+ 매칭권 + 매칭권 + + (보유: {editingMember.matchingTicketCount}개) + +
+ + {/* 작업 방식 선택 */} +
+ {(["NONE", "ADD", "REMOVE"] as const).map((act) => ( + + ))} +
+ + {/* 수량 설정 */} + {matchingAction !== "NONE" && ( +
+ + 조정 수량 + +
+ + + setMatchingQuantity( + Math.max(1, parseInt(e.target.value) || 1), + ) + } + className="h-6 w-10 [appearance:textfield] border-0 bg-[#161827] text-center text-xs font-bold text-white focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + +
+
+ )} +
+ + {/* ─── 2. 옵션권 설정 ─── */} +
+
+ 옵션권 + 옵션권 + + (보유: {editingMember.optionTicketCount}개) + +
+ + {/* 작업 방식 선택 */} +
+ {(["NONE", "ADD", "REMOVE"] as const).map((act) => ( + + ))} +
+ + {/* 수량 설정 */} + {optionAction !== "NONE" && ( +
+ + 조정 수량 + +
+ + + setOptionQuantity( + Math.max(1, parseInt(e.target.value) || 1), + ) + } + className="h-6 w-10 [appearance:textfield] border-0 bg-[#161827] text-center text-xs font-bold text-white focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + +
+
+ )} +
+ + {/* 3. 조정 사유 작성 */} +
+ + 조정 사유 (필수) + + setAdjustReason(e.target.value)} + maxLength={255} + className="w-full rounded-xl border border-[#1e2030] bg-[#161827] px-3.5 py-2.5 text-xs text-white placeholder-[#4a4e69] transition-all duration-200 focus:border-[#06b6d4]/50 focus:outline-none" + /> +
+
+ + {/* 실시간 계산결과 미리보기 */} +
+
+ 최종 수량 변동 예상 +
+ + {/* 매칭권 */} +
+
+ 매칭권 + 매칭권 +
+
+ + {editingMember.matchingTicketCount}개 + + + + {getExpectedQuantity("MATCHING_TICKET")}개 + {matchingAction !== "NONE" && + ` (${matchingAction === "ADD" ? "+" : "-"}${matchingQuantity})`} + +
+
+ + {/* 옵션권 */} +
+
+ 옵션권 + 옵션권 +
+
+ + {editingMember.optionTicketCount}개 + + + + {getExpectedQuantity("OPTION_TICKET")}개 + {optionAction !== "NONE" && + ` (${optionAction === "ADD" ? "+" : "-"}${optionQuantity})`} + +
+
+
+ + {/* 경고 알림 (차감 가능량 초과 시) */} + {(isInsufficient("MATCHING_TICKET") || + isInsufficient("OPTION_TICKET")) && ( +
+ +

+ 보유한 수량보다 차감하려는 수량이 많습니다. 요청 시 + 에러(`ITEM-001`)가 발생할 수 있습니다. +

+
+ )} + + {/* 작업 버튼 */} +
+ + +
+
+
+ )} +
+ ); +} diff --git a/app/adminpage/users/page.tsx b/app/adminpage/users/page.tsx new file mode 100644 index 0000000..a51dd5b --- /dev/null +++ b/app/adminpage/users/page.tsx @@ -0,0 +1,28 @@ +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; +import { serverApi } from "@/lib/server-api"; +import AdminMembers from "./_components/AdminMembers"; + +export default async function AdminMembersPage() { + const queryClient = new QueryClient(); + + // 서버사이드에서 전체 사용자 목록을 미리 가져옵니다. (기본 검색어 없음) + await queryClient.prefetchQuery({ + queryKey: ["adminMembers", undefined], + queryFn: async () => { + const res = await serverApi.get({ + path: "/api/v1/admin/users", + }); + return res.data; + }, + }); + + return ( + + + + ); +} diff --git a/app/chat-list/_components/ScreenChatList.tsx b/app/chat-list/_components/ScreenChatList.tsx index 4b37513..7aa87fd 100644 --- a/app/chat-list/_components/ScreenChatList.tsx +++ b/app/chat-list/_components/ScreenChatList.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { BackButton } from "@/components/ui/BackButton"; import { useChatRooms, type ChatRoom } from "@/hooks/useChatRooms"; import { useEffect, useMemo } from "react"; +import NoMatchingList from "@/app/matching-list/_components/NoMatchingList"; type ChatListItem = { id: string; @@ -150,11 +151,17 @@ export default function ScreenChatList() { 가볍게 인사는 어떠신가요? -
- {chatItems.map((item) => ( - - ))} -
+ {chatItems.length === 0 ? ( +
+ +
+ ) : ( +
+ {chatItems.map((item) => ( + + ))} +
+ )} ); } diff --git a/app/chat/[chatid]/_components/PartnerProfileModal.tsx b/app/chat/[chatid]/_components/PartnerProfileModal.tsx index a1a211e..7488e57 100644 --- a/app/chat/[chatid]/_components/PartnerProfileModal.tsx +++ b/app/chat/[chatid]/_components/PartnerProfileModal.tsx @@ -104,26 +104,6 @@ const PartnerProfileModal = ({ )} /> - {instagramUrl ? ( - e.stopPropagation()} - className="flex h-4 w-4 cursor-pointer items-center justify-center transition-opacity hover:opacity-80" - aria-label="인스타그램 바로가기" - > - - - ) : ( - - )}
@@ -222,7 +202,7 @@ const PartnerProfileModal = ({ background: "linear-gradient(93.29deg, #FF775E 0.01%, #FF4D61 47.4%, #E83ABC 100%)", }} - className="flex h-[42px] w-full items-center px-4 backdrop-blur-[50px]" + className="flex h-[42px] w-full items-center justify-between px-4 backdrop-blur-[50px]" > {partner.socialType === "KAKAO" ? ( diff --git a/app/firebase-messaging-sw.js/route.ts b/app/firebase-messaging-sw.js/route.ts index a6e5c20..5cd2028 100644 --- a/app/firebase-messaging-sw.js/route.ts +++ b/app/firebase-messaging-sw.js/route.ts @@ -26,7 +26,11 @@ if (!firebase.apps.length) { const messaging = firebase.messaging(); messaging.onBackgroundMessage((payload) => { - console.log("[Service Worker] 백그라운드 메시지 수신:", payload); + console.log( + "%c🔔 [Service Worker FCM] 백그라운드 알림 수신!", + "background: #111827; color: #06b6d4; font-size: 13px; font-weight: bold; padding: 4px 8px; border-radius: 4px;" + ); + console.log("👉 페이로드 전체 구조:", JSON.stringify(payload, null, 2)); // 1. 만약 payload.notification 필드가 존재한다면, FCM SDK가 백그라운드에서 자동으로 알림을 보여줍니다. // 이 상황에서 수동으로 showNotification을 또 부르면 알림이 2개 뜨게 되므로 수동 팝업은 패스합니다! diff --git a/app/layout.tsx b/app/layout.tsx index 5a4753c..80599f0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,6 +6,7 @@ import { QueryProvider } from "@/providers/query-provider"; // import { ServiceStatusProvider } from "@/providers/service-status-provider"; // import { getInitialMaintenanceStatus } from "@/lib/status"; import FcmInitializer from "@/components/common/FcmInitializer"; +import PwaInitializer from "@/components/common/PwaInitializer"; import ChatSocketInitializer from "@/components/common/ChatSocketInitializer"; import ToastContainer from "@/components/common/ToastContainer"; @@ -32,6 +33,11 @@ export const metadata: Metadata = { shortcut: "/logo/icon.png", apple: [{ url: "/logo/icon.png", sizes: "180x180", type: "image/png" }], }, + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "코매칭", + }, openGraph: { title: "코매칭 - 대학축제 커플매칭", description: "대학교 축제에서 운명의 인연을 만나보세요!", @@ -59,6 +65,7 @@ export const viewport = { initialScale: 1, maximumScale: 1, userScalable: false, + themeColor: "#ea580c", }; export default async function RootLayout({ @@ -80,6 +87,7 @@ export default async function RootLayout({
+ {children} diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 0000000..ddb5af4 --- /dev/null +++ b/app/manifest.ts @@ -0,0 +1,27 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "코매칭", + short_name: "코매칭", + description: "대학교 축제에서 운명의 인연을 만나보세요!", + start_url: "/", + display: "standalone", + background_color: "#ffffff", + theme_color: "#ea580c", + icons: [ + { + src: "/logo/icon.png", + sizes: "192x192", + type: "image/png", + purpose: "any", + }, + { + src: "/logo/icon.png", + sizes: "512x512", + type: "image/png", + purpose: "any", + }, + ], + }; +} diff --git a/app/matching-list/_components/NoMatchingList.tsx b/app/matching-list/_components/NoMatchingList.tsx index 6e96dd1..0bd48e8 100644 --- a/app/matching-list/_components/NoMatchingList.tsx +++ b/app/matching-list/_components/NoMatchingList.tsx @@ -9,9 +9,13 @@ import { useParticipantsCount } from "@/hooks/useParticipantsCount"; interface NoMatchingListProps { nickname?: string; + type?: "matching" | "chat"; } -const NoMatchingList = ({ nickname = "회원" }: NoMatchingListProps) => { +const NoMatchingList = ({ + nickname = "회원", + type = "matching", +}: NoMatchingListProps) => { const router = useRouter(); const { data: participantsData } = useParticipantsCount(); const count = participantsData?.data?.count ?? 732; // 기본값 732 유지 @@ -24,17 +28,31 @@ const NoMatchingList = ({ nickname = "회원" }: NoMatchingListProps) => {
{/* Message */} -

- 아직 매칭된 상대가 없어요. -
- 현재{" "} - - {count.toLocaleString()} - - 명이 참여중이에요. -
- 나와 딱 맞는 이성친구를 만들어봐요! -

+ {type === "chat" ? ( +

+ 아직 대화 중인 채팅방이 없어요. +
+ 현재{" "} + + {count.toLocaleString()} + + 명이 참여중이에요. +
+ 매칭된 상대와 대화를 시작해보세요! +

+ ) : ( +

+ 아직 매칭된 상대가 없어요. +
+ 현재{" "} + + {count.toLocaleString()} + + 명이 참여중이에요. +
+ 나와 딱 맞는 이성친구를 만들어봐요! +

+ )} {/* button */}
- {product.description && ( + {product.description ? ( {product.description} + ) : ( +   )}
{ + code: string; + status: number; + message: string; + data: T; +} + +export interface AdjustMemberItemsBody { + itemType: "MATCHING_TICKET" | "OPTION_TICKET"; + quantity: number; + action: "ADD" | "REMOVE"; + reason: string; +} + +/* ── 1. 관리자 사용자 목록 조회 ── */ +const fetchAdminMembers = async ( + keyword?: string, +): Promise> => { + const params = keyword ? { keyword } : {}; + const { data } = await api.get>( + "/api/v1/admin/users", + { params }, + ); + return data; +}; + +/* ── 훅: 사용자 목록 조회 ── */ +export const useAdminMembers = (keyword?: string) => { + return useQuery({ + queryKey: ["adminMembers", keyword], + queryFn: () => fetchAdminMembers(keyword), + }); +}; + +/* ── 2. 관리자 특정 사용자 상세 정보 조회 ── */ +const fetchAdminMemberDetail = async ( + memberId: number, +): Promise> => { + const { data } = await api.get>( + `/api/v1/admin/users/${memberId}`, + ); + return data; +}; + +/* ── 훅: 사용자 상세 정보 조회 ── */ +export const useAdminMemberDetail = (memberId: number) => { + return useQuery({ + queryKey: ["adminMemberDetail", memberId], + queryFn: () => fetchAdminMemberDetail(memberId), + enabled: !!memberId, + }); +}; + +/* ── 3. 관리자 사용자 아이템 수량 조정 (실제 API 연동) ── */ +const adjustMemberItems = async ({ + memberId, + body, +}: { + memberId: number; + body: AdjustMemberItemsBody; +}): Promise> => { + const { data } = await api.patch>( + `/api/v1/admin/users/${memberId}/items`, + body, + ); + return data; +}; + +/* ── 훅: 사용자 아이템 수량 조정 ── */ +export const useAdjustMemberItems = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + memberId, + body, + }: { + memberId: number; + body: AdjustMemberItemsBody; + }) => adjustMemberItems({ memberId, body }), + onSuccess: (_, variables) => { + // 목록 캐시 갱신 + queryClient.invalidateQueries({ queryKey: ["adminMembers"] }); + // 해당 사용자의 상세 캐시 갱신 + queryClient.invalidateQueries({ + queryKey: ["adminMemberDetail", variables.memberId], + }); + }, + onError: (error: AxiosError<{ code?: string; message?: string }>) => { + const errorData = error.response?.data; + console.error( + "❌ 아이템 조정 실패:", + errorData?.message || error.message, + ); + }, + }); +}; diff --git a/lib/firebase.ts b/lib/firebase.ts index 2956c5e..788c649 100644 --- a/lib/firebase.ts +++ b/lib/firebase.ts @@ -75,8 +75,14 @@ export async function registerServiceWorkerAndGetToken() { // 포그라운드 메시지 수신 리스너 onMessage(messaging, (payload) => { - console.log("🔔 [FCM Foreground Payload] 수신된 알림 전체 구조:"); - console.log(JSON.stringify(payload, null, 2)); + console.log( + "%c🔔 [Firebase Cloud Messaging] 포그라운드 알림 수신!", + "background: #1e1b4b; color: #a855f7; font-size: 13px; font-weight: bold; padding: 4px 8px; border-radius: 4px;", + ); + console.log( + "👉 수신된 알림 전체 구조:", + JSON.stringify(payload, null, 2), + ); console.dir(payload); // notification 이나 data 필드에서 어떻게든 정보를 추출합니다. diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..bcb86f7 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,52 @@ +const CACHE_NAME = "comatching-cache-v1"; +const ASSETS_TO_CACHE = ["/", "/logo/icon.png", "/logo/comatching-logo.svg"]; + +// Install Event +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(ASSETS_TO_CACHE); + }), + ); + self.skipWaiting(); +}); + +// Activate Event +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => { + return Promise.all( + keys.map((key) => { + if (key !== CACHE_NAME) { + return caches.delete(key); + } + }), + ); + }), + ); + self.clients.claim(); +}); + +// Fetch Event +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + + // Do not intercept FCM route or API calls + if ( + event.request.url.includes("/api/") || + event.request.url.includes("firebase") + ) { + return; + } + + event.respondWith( + caches.match(event.request).then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + return fetch(event.request).catch(() => { + // Offline fallback could go here + }); + }), + ); +});