feat(push): 네이티브 앱 자동 등록 — 로그인 직후 권한 prompt + FCM 토큰 등록
설치자 액션 0: 회원정보 안 가도 로그인만 하면 자동으로 알림 받기 시작. src/components/native-push-auto-register.tsx (신규, UI 없음): - Capacitor 환경 감지 - requestPermissions() 자동 호출 (안드로이드 13+ 시스템 prompt) - 허용되면 FCM register → registration 이벤트로 토큰 받기 - /api/m/push/fcm-token 로 토큰 등록 - sessionStorage 로 같은 세션 안 중복 방지 - 401 (로그인 전) 이면 키 제거 → 로그인 후 자동 재시도 src/app/(main)/layout.tsx: - 인증된 사용자 화면에 <NativePushAutoRegister /> 마운트 - 일반 브라우저에선 즉시 return null — 동작 0 수정: - src/components/push-optin.tsx: declare global Window 제거 (Capacitor SDK 와 충돌) → getCap() helper 로 통일 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { useMenuStore } from "@/store/menu-store";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { Loading } from "@/components/ui/loading";
|
||||
import { NativePushAutoRegister } from "@/components/native-push-auto-register";
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
@@ -24,6 +25,8 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* 로그인 직후 자동으로 안드로이드 알림 권한 + FCM 토큰 등록 (Capacitor 앱만, UI 없음) */}
|
||||
<NativePushAutoRegister />
|
||||
{/* 사이드바 — 데스크탑은 정상, 모바일은 오버레이로 등장 */}
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
// Capacitor 네이티브 앱 자동 푸시 등록 — UI 없음, 백그라운드 동작.
|
||||
// 로그인 후 첫 페이지 로드 시 자동으로:
|
||||
// 1. 안드로이드 시스템 알림 권한 prompt (POST_NOTIFICATIONS)
|
||||
// 2. 권한 허용되면 FCM 토큰 발급
|
||||
// 3. 백엔드 /api/m/push/fcm-token 에 등록
|
||||
// 4. 새 상품/공지 알림이 자동 도착 시작
|
||||
//
|
||||
// 일반 웹 브라우저(Capacitor 아님)에서는 아무것도 안 함 — 회원정보의 PushOptIn 카드 사용.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// Capacitor 글로벌 타입은 push-optin.tsx 의 declare global 과 동일 — 한 쪽만 선언해도 충분.
|
||||
// 여기서는 import 한 PushOptIn 없으므로 별도 type 만 정의.
|
||||
interface PN {
|
||||
requestPermissions: () => Promise<{ receive: "granted" | "denied" | "prompt" }>;
|
||||
register: () => Promise<void>;
|
||||
addListener: (event: string, cb: (data: { value?: string; error?: string }) => void) => Promise<{ remove: () => void }>;
|
||||
}
|
||||
function getCap(): { isNativePlatform?: () => boolean; Plugins?: { PushNotifications?: PN } } | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return (window as unknown as { Capacitor?: { isNativePlatform?: () => boolean; Plugins?: { PushNotifications?: PN } } }).Capacitor;
|
||||
}
|
||||
|
||||
const SESSION_KEY = "momo-native-push-tried";
|
||||
|
||||
export function NativePushAutoRegister() {
|
||||
const ranRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ranRef.current) return;
|
||||
ranRef.current = true;
|
||||
|
||||
const cap = getCap();
|
||||
const PN = cap?.Plugins?.PushNotifications;
|
||||
if (!cap?.isNativePlatform?.() || !PN) return;
|
||||
|
||||
// 같은 세션 내 중복 호출 방지 (페이지 이동 마다 다시 묻지 않음)
|
||||
try { if (sessionStorage.getItem(SESSION_KEY)) return; } catch { /* ignore */ }
|
||||
try { sessionStorage.setItem(SESSION_KEY, "1"); } catch { /* ignore */ }
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// 1) 권한 요청 (이미 granted 면 즉시 granted 반환)
|
||||
const perm = await PN.requestPermissions();
|
||||
if (perm.receive !== "granted") {
|
||||
console.log("[native-push] 권한 거부:", perm.receive);
|
||||
return;
|
||||
}
|
||||
// 2) FCM 토큰 받기 (10초 안에)
|
||||
const token = await new Promise<string | null>((resolve) => {
|
||||
let resolved = false;
|
||||
const timeout = setTimeout(() => { if (!resolved) { resolved = true; resolve(null); } }, 10000);
|
||||
PN.addListener("registration", (d) => {
|
||||
if (resolved) return; resolved = true; clearTimeout(timeout);
|
||||
resolve(d.value || null);
|
||||
});
|
||||
PN.addListener("registrationError", (d) => {
|
||||
if (resolved) return; resolved = true; clearTimeout(timeout);
|
||||
console.error("[native-push] registrationError:", d);
|
||||
resolve(null);
|
||||
});
|
||||
PN.register();
|
||||
});
|
||||
if (!token) {
|
||||
console.warn("[native-push] FCM 토큰 발급 실패 — 다음 로그인에서 재시도");
|
||||
return;
|
||||
}
|
||||
// 3) 백엔드 등록 — 401 이면 로그인 안 됐다는 뜻, 다음 로그인 시 재시도
|
||||
const r = await fetch("/api/m/push/fcm-token", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, userAgent: navigator.userAgent }),
|
||||
});
|
||||
if (r.ok) {
|
||||
console.log("[native-push] ✔ FCM 토큰 등록 완료");
|
||||
} else if (r.status === 401) {
|
||||
// 로그인 전이면 세션 키 제거 → 로그인 후 다시 시도되도록
|
||||
try { sessionStorage.removeItem(SESSION_KEY); } catch { /* ignore */ }
|
||||
} else {
|
||||
console.warn(`[native-push] 백엔드 등록 실패 (HTTP ${r.status})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[native-push] 자동 등록 예외:", err);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -5,19 +5,15 @@ import { Bell, BellOff, AlertCircle } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// Capacitor 글로벌 — APK 안의 WebView 에 자동 주입됨. 일반 브라우저에선 undefined.
|
||||
interface CapacitorGlobal {
|
||||
isNativePlatform?: () => boolean;
|
||||
Plugins?: {
|
||||
PushNotifications?: {
|
||||
requestPermissions: () => Promise<{ receive: "granted" | "denied" | "prompt" }>;
|
||||
register: () => Promise<void>;
|
||||
addListener: (event: string, cb: (data: { value?: string; error?: string }) => void) => Promise<{ remove: () => void }>;
|
||||
removeAllListeners?: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
// @capacitor/core 가 Window.Capacitor 를 자체 정의하므로 declare global 안 함.
|
||||
interface CapPN {
|
||||
requestPermissions: () => Promise<{ receive: "granted" | "denied" | "prompt" }>;
|
||||
register: () => Promise<void>;
|
||||
addListener: (event: string, cb: (data: { value?: string; error?: string }) => void) => Promise<{ remove: () => void }>;
|
||||
}
|
||||
declare global {
|
||||
interface Window { Capacitor?: CapacitorGlobal }
|
||||
function getCap(): { isNativePlatform?: () => boolean; Plugins?: { PushNotifications?: CapPN } } | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return (window as unknown as { Capacitor?: { isNativePlatform?: () => boolean; Plugins?: { PushNotifications?: CapPN } } }).Capacitor;
|
||||
}
|
||||
|
||||
// VAPID 공개키(base64url) → Uint8Array
|
||||
@@ -186,7 +182,7 @@ export function PushOptIn({ variant = "compact" }: PushOptInProps) {
|
||||
|
||||
// Capacitor native APK 분기 — FCM 토큰 등록 흐름
|
||||
const turnOnNative = useCallback(async (): Promise<boolean> => {
|
||||
const cap = typeof window !== "undefined" ? window.Capacitor : undefined;
|
||||
const cap = getCap();
|
||||
const PN = cap?.Plugins?.PushNotifications;
|
||||
if (!PN) return false;
|
||||
const perm = await PN.requestPermissions();
|
||||
@@ -237,7 +233,7 @@ export function PushOptIn({ variant = "compact" }: PushOptInProps) {
|
||||
// 사용자가 OS 설정에서 권한을 풀고 돌아온 케이스에 React state 가 stale 해도 동작하도록.
|
||||
const turnOn = useCallback(async (): Promise<boolean> => {
|
||||
// Capacitor 환경이면 native 분기 우선
|
||||
if (typeof window !== "undefined" && window.Capacitor?.isNativePlatform?.()) {
|
||||
if (typeof window !== "undefined" && getCap()?.isNativePlatform?.()) {
|
||||
return await turnOnNative();
|
||||
}
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user