diff --git a/app/firebase-messaging-sw.js/route.ts b/app/firebase-messaging-sw.js/route.ts index 1c08b04..9d9f32f 100644 --- a/app/firebase-messaging-sw.js/route.ts +++ b/app/firebase-messaging-sw.js/route.ts @@ -28,13 +28,22 @@ const messaging = firebase.messaging(); messaging.onBackgroundMessage((payload) => { console.log("[Service Worker] 백그라운드 메시지 수신:", payload); - const notificationTitle = payload.notification?.title || "새 알림"; - const notificationOptions = { - body: payload.notification?.body || "", - icon: payload.notification?.icon || "/logo/logo.svg", - }; - - self.registration.showNotification(notificationTitle, notificationOptions); + // 1. 만약 payload.notification 필드가 존재한다면, FCM SDK가 백그라운드에서 자동으로 알림을 보여줍니다. + // 이 상황에서 수동으로 showNotification을 또 부르면 알림이 2개 뜨게 되므로 수동 팝업은 패스합니다! + if (payload.notification) { + console.log("[Service Worker] notification 필드 존재로 인한 자동 알림 완료. 수동 노출 생략."); + return; + } + + // 2. 오직 payload.notification이 없고 payload.data만 있는 'Data-only Message' 형태일 때만 수동으로 띄웁니다. + if (payload.data) { + const notificationTitle = payload.data.title || "새 알림"; + const notificationOptions = { + body: payload.data.body || payload.data.message || "", + icon: payload.data.icon || "/logo/logo.svg", + }; + self.registration.showNotification(notificationTitle, notificationOptions); + } }); `; diff --git a/app/layout.tsx b/app/layout.tsx index 59bc23a..3382155 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,6 +7,7 @@ import { QueryProvider } from "@/providers/query-provider"; // import { getInitialMaintenanceStatus } from "@/lib/status"; import FcmInitializer from "@/components/common/FcmInitializer"; import ChatSocketInitializer from "@/components/common/ChatSocketInitializer"; +import ToastContainer from "@/components/common/ToastContainer"; const pretendard = localFont({ src: "./fonts/PretendardVariable.woff2", @@ -72,6 +73,7 @@ export default async function RootLayout({ + {children} {/* */} diff --git a/app/login/_components/LoginForm.tsx b/app/login/_components/LoginForm.tsx index d295078..4f729b2 100644 --- a/app/login/_components/LoginForm.tsx +++ b/app/login/_components/LoginForm.tsx @@ -58,9 +58,12 @@ export const LoginForm = () => {
- + alert("미지원 서비스입니다.")} + className="cursor-pointer" + > 이메일 찾기 - + | 비밀번호 변경 diff --git a/app/matching-list/_components/YesMatchingList.tsx b/app/matching-list/_components/YesMatchingList.tsx index 5547951..077516b 100644 --- a/app/matching-list/_components/YesMatchingList.tsx +++ b/app/matching-list/_components/YesMatchingList.tsx @@ -44,13 +44,15 @@ const YesMatchingList = ({ // 검색 필터 if (searchQuery.trim()) { const query = searchQuery.trim().toLowerCase(); + const cleanQuery = query.replace("살", "").trim(); result = result.filter((item) => { const p = item.partner; + const rawAge = p.age || (p.birthDate ? getAge(p.birthDate) : null); return ( p.nickname.toLowerCase().includes(query) || p.mbti.toLowerCase().includes(query) || p.major.toLowerCase().includes(query) || - (p.birthDate && String(getAge(p.birthDate)).includes(query)) + (rawAge !== null && String(rawAge).includes(cleanQuery)) ); }); } diff --git a/app/matching-result/_components/ScreenMatchingResult.tsx b/app/matching-result/_components/ScreenMatchingResult.tsx index 2ae2c3d..8ba93f4 100644 --- a/app/matching-result/_components/ScreenMatchingResult.tsx +++ b/app/matching-result/_components/ScreenMatchingResult.tsx @@ -57,7 +57,7 @@ const ScreenMatchingResult = () => { return (
router.push("/main")} + onClick={() => router.push("/matching")} text={
{displayedText}; } +const formatMBTISelection = (mbti: string): string => { + const mbtiMap: Record = { + E: "외향형", + I: "내향형", + S: "감각형", + N: "직관형", + T: "사고형", + F: "감정형", + J: "판단형", + P: "인식형", + }; + const chars = mbti.split("").filter(Boolean); + const labels = chars.map((c) => mbtiMap[c] || c); + if (labels.length === 2) { + return `${labels[0]}, 그리고 ${labels[1]}`; + } + return labels.join(", "); +}; + export default function ImportantOptionDrawer({ trigger, onSelect, @@ -224,7 +243,7 @@ export default function ImportantOptionDrawer({
) : ( -
+
{typingStep >= 1 && (
@@ -241,7 +260,11 @@ export default function ImportantOptionDrawer({
diff --git a/app/matching/_components/MatchingAgeOption.tsx b/app/matching/_components/MatchingAgeOption.tsx index 85d6ed9..bdf5bb0 100644 --- a/app/matching/_components/MatchingAgeOption.tsx +++ b/app/matching/_components/MatchingAgeOption.tsx @@ -61,9 +61,14 @@ export default function MatchingAgeOption({ onConfirm={handleConfirm} trigger={ diff --git a/app/profile-image/_components/TermsDrawer.tsx b/app/profile-image/_components/TermsDrawer.tsx index 9fb6716..9b69044 100644 --- a/app/profile-image/_components/TermsDrawer.tsx +++ b/app/profile-image/_components/TermsDrawer.tsx @@ -148,8 +148,12 @@ const TermsDrawer = ({ children }: TermsDrawerProps) => { if (result.success) { setIsOpen(false); - clearProfile(); + // 페이지 이동이 완료되기 전에 프로필 데이터가 비워져서 '여성 강아지(female_dog)' 기본 이미지로 화면이 깜빡이는 현상을 방지하기 위해, + // 먼저 이동하고 약간의 딜레이(500ms) 후에 프로필 저장소를 클리어합니다. router.push("/main"); + setTimeout(() => { + clearProfile(); + }, 500); } else { alert(result.message); } diff --git a/app/register/_components/ScreenRegister.tsx b/app/register/_components/ScreenRegister.tsx index 28dea8f..b0a01d2 100644 --- a/app/register/_components/ScreenRegister.tsx +++ b/app/register/_components/ScreenRegister.tsx @@ -55,7 +55,8 @@ export const ScreenRegister = () => { { onSuccess: (data) => { if (data.status === 200) { - // TODO: 회원가입 완료 후 이동 (예: router.push("/login")) + alert("가입이 완료되었습니다."); + router.push("/login"); } else { alert("회원가입에 실패했습니다. 다시 시도해주세요."); } diff --git a/app/register/_components/VerificationStep.tsx b/app/register/_components/VerificationStep.tsx index c74b059..60302f3 100644 --- a/app/register/_components/VerificationStep.tsx +++ b/app/register/_components/VerificationStep.tsx @@ -143,7 +143,7 @@ export const VerificationStep = ({
-
+
인증번호가 오지 않았나요?
이전 단계에서 이메일을 재확인하거나, 스팸함을 확인해 diff --git a/app/reset/password/new/_components/ScreenNewPasswordPage.tsx b/app/reset/password/new/_components/ScreenNewPasswordPage.tsx index 4c88021..71c5a3c 100644 --- a/app/reset/password/new/_components/ScreenNewPasswordPage.tsx +++ b/app/reset/password/new/_components/ScreenNewPasswordPage.tsx @@ -74,6 +74,10 @@ const ScreenNewPasswordPage = () => { }, onError: (error) => { + // '존재하지 않는 유저' 에러가 이미 표시된 상태라면, 중복 클릭 시 다른 에러(인증번호 오류 등)로 덮어쓰지 않고 유지합니다. + if (errorMessage.includes("존재하지 않는")) { + return; + } setErrorMessage( error.message || "비밀번호 변경에 실패했습니다. 다시 시도해 주세요.", diff --git a/components/common/ToastContainer.tsx b/components/common/ToastContainer.tsx new file mode 100644 index 0000000..c7f0be9 --- /dev/null +++ b/components/common/ToastContainer.tsx @@ -0,0 +1,58 @@ +"use client"; +import React from "react"; +import { useToastStore } from "@/stores/toast-store"; +import Image from "next/image"; +import { X } from "lucide-react"; + +export default function ToastContainer() { + const { toast, hideToast } = useToastStore(); + + if (!toast) return null; + + return ( +
+
+ {/* 알림 아이콘: COMAtching 그라데이션 브랜드 테두리 적용 */} +
+
+ COMAtching { + (e.target as HTMLElement).style.display = "none"; + }} + /> +
+
+ + {/* 텍스트 내용 */} +
+ + {toast.title} + + + {toast.body} + +
+ + {/* 닫기 버튼 */} + +
+
+ ); +} diff --git a/hooks/useChatMemberProfile.ts b/hooks/useChatMemberProfile.ts index b77ffd8..38023cb 100644 --- a/hooks/useChatMemberProfile.ts +++ b/hooks/useChatMemberProfile.ts @@ -31,7 +31,7 @@ export const useChatMemberProfile = (memberId?: number) => { queryKey: ["chatMemberProfile", memberId], queryFn: () => fetchChatMemberProfile(memberId!), enabled: !!memberId, - staleTime: 1000 * 60 * 60, // 📡 상대방 프로필 정보는 1시간 동안 완전 신선(Fresh)하다고 판단하여 API 재호출 완벽 차단 - gcTime: 1000 * 60 * 90, // 🧠 1시간 30분 동안 메모리에 든든하게 캐시 보관 + staleTime: 1000 * 10, // 📡 10초 동안은 캐시를 유지하여 잦은 재진입 시 불필요한 API 호출을 차단합니다 + gcTime: 1000 * 15, // 🧠 15초 뒤에 미사용 캐시를 정리합니다 }); }; diff --git a/lib/constants/defaultProfiles.ts b/lib/constants/defaultProfiles.ts index 1d9b438..faa9e16 100644 --- a/lib/constants/defaultProfiles.ts +++ b/lib/constants/defaultProfiles.ts @@ -120,6 +120,14 @@ export function getAutoSwitchProfileIdByGender( return null; } + // 뱀(여성전용) ↔ 말(남성전용) 특수 스위칭 매핑 + if (currentProfileId === "snake" && newGender === "MALE") { + return "horse"; + } + if (currentProfileId === "horse" && newGender === "FEMALE") { + return "snake"; + } + const asset = DEFAULT_PROFILE_ASSETS.find((p) => p.id === currentProfileId); if (!asset) { return null; diff --git a/lib/firebase.ts b/lib/firebase.ts index 1754ee9..2956c5e 100644 --- a/lib/firebase.ts +++ b/lib/firebase.ts @@ -3,6 +3,7 @@ import { initializeApp, getApps } from "firebase/app"; import { getAnalytics } from "firebase/analytics"; import { getMessaging, getToken, onMessage } from "firebase/messaging"; +import { useToastStore } from "@/stores/toast-store"; export const firebaseConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, @@ -74,8 +75,24 @@ export async function registerServiceWorkerAndGetToken() { // 포그라운드 메시지 수신 리스너 onMessage(messaging, (payload) => { - console.log("[Foreground] 메시지 수신:", payload); - // 여기에 포그라운드 알림 UI 처리 + console.log("🔔 [FCM Foreground Payload] 수신된 알림 전체 구조:"); + console.log(JSON.stringify(payload, null, 2)); + console.dir(payload); + + // notification 이나 data 필드에서 어떻게든 정보를 추출합니다. + const title = + payload.notification?.title || payload.data?.title || "새로운 알림"; + const body = + payload.notification?.body || + payload.data?.body || + payload.data?.message || + ""; + + // 커스텀 인앱 토스트 알림 노출 + useToastStore.getState().showToast({ + title, + body, + }); }); return token; diff --git a/lib/types/matching.ts b/lib/types/matching.ts index 3e5828a..82dd764 100644 --- a/lib/types/matching.ts +++ b/lib/types/matching.ts @@ -6,7 +6,7 @@ export type HobbyOption = | "SPORTS" | "CULTURE" | "MUSIC" - | "TRAVEL" + | "LEISURE" | "DAILY" | "GAME"; diff --git a/lib/utils/profile.ts b/lib/utils/profile.ts index bc11b93..0bbd373 100644 --- a/lib/utils/profile.ts +++ b/lib/utils/profile.ts @@ -36,6 +36,7 @@ export const getProfileImageUrl = ( const animalId = filename .replace("animal_", "") .replace("default_", "") + .replace(/_(male|female)/i, "") // _male 또는 _female 접미사 제거 .replace(/\d+.*$/, "") // animal_dinosaur1.png -> dinosaur .replace(/\..*$/, ""); // 확장자 제거 diff --git a/next.config.ts b/next.config.ts index ae1e456..c1b5bd0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -32,6 +32,10 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "comatching.site", }, + { + protocol: "https", + hostname: "srv.comatching.site", + }, { protocol: "https", hostname: "comatching5.s3.ap-northeast-2.amazonaws.com", diff --git a/stores/toast-store.ts b/stores/toast-store.ts new file mode 100644 index 0000000..39163cd --- /dev/null +++ b/stores/toast-store.ts @@ -0,0 +1,33 @@ +import { create } from "zustand"; + +interface Toast { + id: number; + title: string; + body: string; + icon?: string; +} + +interface ToastState { + toast: Toast | null; + showToast: (params: { title: string; body: string; icon?: string }) => void; + hideToast: () => void; +} + +export const useToastStore = create((set) => ({ + toast: null, + showToast: (params) => { + const id = Date.now(); + set({ toast: { id, ...params } }); + + // 4초 후 자동으로 토스트 닫기 + setTimeout(() => { + set((state) => { + if (state.toast?.id === id) { + return { toast: null }; + } + return state; + }); + }, 4000); + }, + hideToast: () => set({ toast: null }), +}));