feat(WEB): FCM 푸시 알림 시스템 구현

- FCMProvider 컨텍스트 및 useFCM 훅 추가
- Capacitor FCM 플러그인 통합
- 알림 사운드 파일 추가 (default.wav, push_notification.wav)
- Firebase 메시징 패키지 의존성 추가
This commit is contained in:
2025-12-30 17:16:47 +09:00
parent d38b1242d7
commit f400f01db7
12 changed files with 927 additions and 1039 deletions

380
src/lib/capacitor/fcm.ts Normal file
View File

@@ -0,0 +1,380 @@
/**
* FCM (Firebase Cloud Messaging) Push Notification Handler
* Capacitor 앱에서만 동작, 웹 브라우저에서는 무시됨
*
* 포팅 원본: mng/public/js/fcm.js
*
* Payload data 스키마:
* - type: 알림 타입 (invoice_failed, order_completed 등)
* - url: 클릭 시 이동 URL
* - sound_key: 사운드 파일 키 (sounds/{sound_key}.wav)
*
* NOTE: Capacitor 모듈은 동적 import로 로드됨 (웹 빌드 에러 방지)
*/
// 동적 로드된 모듈 캐시 (any 타입 - 웹 빌드 시 모듈 없음)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let Capacitor: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let PushNotifications: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let App: any = null;
/**
* Capacitor 모듈 동적 로드
*/
async function loadCapacitorModules(): Promise<boolean> {
if (Capacitor && PushNotifications && App) return true;
try {
const [coreModule, pushModule, appModule] = await Promise.all([
import('@capacitor/core'),
import('@capacitor/push-notifications'),
import('@capacitor/app'),
]);
Capacitor = coreModule.Capacitor;
PushNotifications = pushModule.PushNotifications;
App = appModule.App;
return true;
} catch {
console.log('[FCM] Capacitor modules not available (web environment)');
return false;
}
}
// ===== 설정 =====
const CONFIG = {
// Next.js 프록시 경로 (HttpOnly 쿠키 자동 포함)
proxyBasePath: '/api/proxy/v1',
fcmTokenKey: 'fcm_token',
soundBasePath: '/sounds/',
defaultSound: 'default',
};
// ===== 타입 정의 =====
export interface PushNotificationData {
type?: string;
url?: string;
sound_key?: string;
[key: string]: unknown;
}
export interface FCMNotification {
title?: string;
body?: string;
data?: PushNotificationData;
}
export type ForegroundNotificationHandler = (notification: FCMNotification) => void;
// ===== 상태 =====
let isAppForeground = true;
let isInitialized = false;
// ===== 유틸리티 함수 =====
/**
* Capacitor 네이티브 환경인지 확인
* 동기 함수 - Capacitor가 로드되지 않은 경우 false 반환
*/
export function isCapacitorNative(): boolean {
if (!Capacitor) return false;
const platform = Capacitor.getPlatform();
return platform === 'ios' || platform === 'android';
}
/**
* 현재 플랫폼 반환
*/
export function getDevicePlatform(): 'ios' | 'android' | 'web' {
if (!Capacitor) return 'web';
const platform = Capacitor.getPlatform();
if (platform === 'ios' || platform === 'android') return platform;
return 'web';
}
/**
* 디바이스 이름 반환 (User-Agent 기반)
*/
function getDeviceName(): string | null {
return navigator.userAgent?.substring(0, 100) || null;
}
/**
* 앱 버전 반환
*/
function getAppVersion(): string | null {
return process.env.NEXT_PUBLIC_APP_VERSION || null;
}
// ===== FCM 핵심 함수 =====
/**
* FCM 초기화 (Capacitor 네이티브 환경에서만 동작)
*
* @param onForegroundNotification 포그라운드 알림 핸들러 (sonner toast 등)
* @returns 초기화 성공 여부
*/
export async function initializeFCM(
onForegroundNotification?: ForegroundNotificationHandler
): Promise<boolean> {
// Capacitor 모듈 동적 로드
const modulesLoaded = await loadCapacitorModules();
if (!modulesLoaded || !Capacitor || !PushNotifications || !App) {
console.log('[FCM] Not running in native app');
return false;
}
// 네이티브 환경 체크
if (!isCapacitorNative()) {
console.log('[FCM] Not running in native app');
return false;
}
if (!Capacitor.isPluginAvailable('PushNotifications')) {
console.log('[FCM] PushNotifications plugin not available');
return false;
}
if (isInitialized) {
console.log('[FCM] Already initialized');
return true;
}
try {
// 앱 상태 리스너 (포그라운드/백그라운드)
if (Capacitor.isPluginAvailable('App')) {
App.addListener('appStateChange', ({ isActive }) => {
isAppForeground = isActive;
console.log('[FCM] App state:', isActive ? 'foreground' : 'background');
});
}
// 기존 리스너 제거
await PushNotifications.removeAllListeners();
// 리스너 등록 (register() 호출 전에 등록해야 함)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
PushNotifications.addListener('registration', async (token: any) => {
console.log('[FCM] Token received:', token.value?.substring(0, 20) + '...');
await handleTokenRegistration(token.value);
});
PushNotifications.addListener('registrationError', (err) => {
console.error('[FCM] Registration error:', err);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
PushNotifications.addListener('pushNotificationReceived', (notification: any) => {
console.log('[FCM] Push received (foreground):', notification);
const fcmNotification: FCMNotification = {
title: notification.title,
body: notification.body,
data: notification.data as PushNotificationData,
};
// 포그라운드 알림 콜백 호출
if (onForegroundNotification) {
onForegroundNotification(fcmNotification);
}
// 사운드 재생
handleForegroundSound(notification.data?.sound_key);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
PushNotifications.addListener('pushNotificationActionPerformed', (action: any) => {
console.log('[FCM] Push action performed:', action);
const url = action.notification?.data?.url;
if (url && typeof url === 'string') {
// URL 이동 (router.push 대신 window.location.href 사용 - 확실한 이동)
window.location.href = url;
}
});
// 권한 요청
const perm = await PushNotifications.requestPermissions();
console.log('[FCM] Push permission:', perm.receive);
if (perm.receive !== 'granted') {
console.log('[FCM] Push permission not granted');
return false;
}
// 토큰 발급 요청 (registration 이벤트 트리거)
await PushNotifications.register();
isInitialized = true;
console.log('[FCM] Initialization completed');
return true;
} catch (error) {
console.error('[FCM] Initialization error:', error);
return false;
}
}
/**
* 토큰 등록 처리
*/
async function handleTokenRegistration(newToken: string): Promise<void> {
if (typeof window === 'undefined') return;
const oldToken = sessionStorage.getItem(CONFIG.fcmTokenKey);
if (oldToken === newToken) {
console.log('[FCM] Token unchanged, skip');
return;
}
const success = await registerTokenToServer(newToken);
if (success) {
sessionStorage.setItem(CONFIG.fcmTokenKey, newToken);
console.log('[FCM] Token saved to sessionStorage');
}
}
/**
* 서버에 토큰 등록 (Next.js 프록시 사용)
*/
async function registerTokenToServer(token: string): Promise<boolean> {
try {
// Next.js 프록시 경로 사용 (HttpOnly 쿠키 자동 포함)
const response = await fetch(`${CONFIG.proxyBasePath}/push/register-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
credentials: 'include', // 쿠키 포함
body: JSON.stringify({
token,
platform: getDevicePlatform(),
device_name: getDeviceName(),
app_version: getAppVersion(),
}),
});
if (response.ok) {
console.log('[FCM] Token registered successfully');
return true;
}
console.error('[FCM] Token registration failed:', response.status);
return false;
} catch (error) {
console.error('[FCM] Failed to send token:', error);
return false;
}
}
/**
* 토큰 해제 (로그아웃 시 호출)
*/
export async function unregisterFCMToken(): Promise<boolean> {
if (typeof window === 'undefined') return true;
const token = sessionStorage.getItem(CONFIG.fcmTokenKey);
if (!token) return true;
try {
// Next.js 프록시 경로 사용
await fetch(`${CONFIG.proxyBasePath}/push/unregister-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ token }),
});
} catch (e) {
console.warn('[FCM] Unregister failed, clearing local token');
}
sessionStorage.removeItem(CONFIG.fcmTokenKey);
isInitialized = false;
console.log('[FCM] Token unregistered');
return true;
}
/**
* 포그라운드 사운드 재생
*/
function handleForegroundSound(soundKey?: string): void {
if (!isAppForeground) return;
if (!soundKey) return;
try {
const soundPath = `${CONFIG.soundBasePath}${soundKey}.wav`;
const audio = new Audio(soundPath);
audio.volume = 0.5;
audio.play().catch((err) => {
console.warn('[FCM] Sound play failed, trying default:', err.message);
// 기본 사운드 시도
if (soundKey !== CONFIG.defaultSound) {
const defaultAudio = new Audio(`${CONFIG.soundBasePath}${CONFIG.defaultSound}.wav`);
defaultAudio.volume = 0.5;
defaultAudio.play().catch(() => {});
}
});
} catch (err) {
console.warn('[FCM] Sound error:', err);
}
}
/**
* FCM 재초기화 (테스트/디버그용)
*/
export async function reinitializeFCM(
onForegroundNotification?: ForegroundNotificationHandler
): Promise<boolean> {
isInitialized = false;
return initializeFCM(onForegroundNotification);
}
/**
* 테스트용 사운드 재생
*/
export function testPlaySound(soundKey: string): void {
handleForegroundSound(soundKey);
}
// ===== 알림 타입별 스타일 =====
export type ToastType = 'info' | 'success' | 'warning' | 'error';
/**
* 알림 타입에 따른 토스트 스타일 결정
*/
export function getToastTypeByNotificationType(type?: string): ToastType {
if (!type) return 'info';
const typeMap: Record<string, ToastType> = {
// 긴급/에러
invoice_failed: 'error',
payment_failed: 'error',
order_cancelled: 'error',
// 경고
approval_required: 'warning',
stock_low: 'warning',
// 성공
order_completed: 'success',
payment_completed: 'success',
approval_approved: 'success',
// 기본
default: 'info',
};
return typeMap[type] || 'info';
}