From 4933655c26f2a6f99ab71eed9f0b771c4efce473 Mon Sep 17 00:00:00 2001 From: chpark Date: Sat, 30 May 2026 16:13:58 +0900 Subject: [PATCH] =?UTF-8?q?fix(push-optin):=20=ED=9A=8C=EC=9B=90=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A1=9C=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=9D=B4=EB=8F=99=20+=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=20=EB=AA=85=ED=99=95=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 요청 사항: 1) 알림 토글을 [출고 요청] 헤더에서 → [회원정보] 페이지의 별도 카드로 이동 2) 켜기 실패 시 SweetAlert 로 '왜 실패했는지' 단계별 사유 표시 - Notification.permission 상태 - Service Worker 등록/준비 단계 - VAPID 키 조회 HTTP 상태 - pushManager.subscribe 예외 메시지 - 서버 endpoint 저장 HTTP 상태 3) HTTPS 보안 컨텍스트 / SW 지원 여부 / 권한 상태 진단 정보 카드 하단 표시 4) Service Worker 명시 register + 10초 timeout — safari/첫방문 케이스의 ready 무한대기 회피 배경: - 관리자 알림 켜기가 silent 실패 (catch 후 console.error 만, UI 는 OFF 상태) - 발송 로그에 targets=0 으로 떠 푸시 미발송 → DB 의 momo_push_subscriptions 미저장 추정 - 사용자에게 '왜 안 켜졌는지' 명확히 알려야 다음 조치(브라우저/앱 권한 설정) 안내 가능 Co-Authored-By: Claude Opus 4.7 --- src/app/(main)/m/orders/new/page.tsx | 11 +- src/app/(main)/profile/page.tsx | 4 + src/components/push-optin.tsx | 214 ++++++++++++++++++++------- 3 files changed, 167 insertions(+), 62 deletions(-) diff --git a/src/app/(main)/m/orders/new/page.tsx b/src/app/(main)/m/orders/new/page.tsx index c3687a5..51d817e 100644 --- a/src/app/(main)/m/orders/new/page.tsx +++ b/src/app/(main)/m/orders/new/page.tsx @@ -4,7 +4,6 @@ import { useEffect, useState, useMemo, useCallback, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Search, ShoppingCart, Plus, Minus, X, Truck, Package, LayoutGrid, List as ListIcon, PhoneCall } from "lucide-react"; import Swal from "sweetalert2"; -import { PushOptIn } from "@/components/push-optin"; interface Item { OBJID: string; @@ -564,12 +563,10 @@ function ItemsBrowse() {
-
-
-

출고 요청

-

현재 재고가 있는 품목을 선택해 상단 장바구니에 담고 [발주 요청] 버튼으로 전송하세요.

-
-
+
+

출고 요청

+

현재 재고가 있는 품목을 선택해 상단 장바구니에 담고 [발주 요청] 버튼으로 전송하세요.

+

푸시 알림 켜기/끄기는 우측 상단 회원정보 에서 설정합니다.

{isAdmin && onBehalfOfCustomer && ( diff --git a/src/app/(main)/profile/page.tsx b/src/app/(main)/profile/page.tsx index 56b56c9..2c48c2a 100644 --- a/src/app/(main)/profile/page.tsx +++ b/src/app/(main)/profile/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, FormEvent } from "react"; import { useRouter } from "next/navigation"; import { Mail, Lock, Building2, User as UserIcon, Phone, FileText, MapPin, Save, X } from "lucide-react"; import Swal from "sweetalert2"; +import { PushOptIn } from "@/components/push-optin"; interface Profile { USER_ID: string; @@ -112,6 +113,9 @@ export default function ProfilePage() {
+ {/* 푸시 알림 토글 — 헤더/주문 페이지에서 옮겨옴 (정확한 진단 정보 + 에러 메시지 노출) */} + + {/* 기본 정보 */}

기본 정보

diff --git a/src/components/push-optin.tsx b/src/components/push-optin.tsx index 938d3bc..91d8653 100644 --- a/src/components/push-optin.tsx +++ b/src/components/push-optin.tsx @@ -1,7 +1,8 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Bell, BellOff } from "lucide-react"; +import { Bell, BellOff, AlertCircle } from "lucide-react"; +import Swal from "sweetalert2"; // VAPID 공개키(base64url) → Uint8Array function urlBase64ToUint8Array(base64String: string): Uint8Array { @@ -25,11 +26,17 @@ const writeIntent = (v: "on" | "off" | null) => { catch { /* ignore */ } }; +interface PushOptInProps { + /** 큰 카드 형태로 표시 (회원정보 페이지용). 기본 false = 작은 헤더 토글 */ + variant?: "compact" | "card"; +} + // 새 품목 판매 알림 켜기/끄기 스위치. (PWA 설치 + 알림 권한 필요) -export function PushOptIn() { +export function PushOptIn({ variant = "compact" }: PushOptInProps) { const [on, setOn] = useState(false); const [busy, setBusy] = useState(false); const [denied, setDenied] = useState(false); + const [statusMsg, setStatusMsg] = useState(""); const bootRef = useRef(false); const supported = @@ -38,37 +45,65 @@ export function PushOptIn() { "PushManager" in window && "Notification" in window; - // 서버에 현재 구독 endpoint 저장 — 의도 'on' 일 때만 호출. - const saveSubscriptionToServer = useCallback(async (sub: PushSubscription): Promise => { + const httpsOK = + typeof window === "undefined" || + location.protocol === "https:" || + location.hostname === "localhost" || + location.hostname === "127.0.0.1"; + + const saveSubscriptionToServer = useCallback(async (sub: PushSubscription): Promise<{ ok: boolean; status: number; msg?: string }> => { const r = await fetch("/api/m/push/subscribe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subscription: sub.toJSON(), userAgent: navigator.userAgent }), }); - return r.ok; + let msg: string | undefined; + if (!r.ok) { + try { msg = (await r.json())?.message; } catch { /* ignore */ } + } + return { ok: r.ok, status: r.status, msg }; }, []); // 권한 granted 전제로 silent 하게 (재)구독. 권한이 default 이면 호출 측에서 먼저 요청. - const subscribeSilently = useCallback(async (): Promise => { - if (Notification.permission !== "granted") return null; - const reg = await navigator.serviceWorker.ready; + // 실패 시 정확한 사유를 throw — UI 가 사용자에게 보여줄 수 있도록. + const subscribeSilently = useCallback(async (): Promise => { + if (Notification.permission !== "granted") throw new Error("알림 권한이 허용되지 않았습니다."); + + // 1) Service Worker 준비 + // 일부 브라우저(safari/iOS, 첫 방문 직후 등)에서 ready 가 무한 대기할 수 있어 + // 명시적으로 등록 시도 + 10초 타임아웃. + let reg: ServiceWorkerRegistration; + try { + reg = await Promise.race([ + navigator.serviceWorker.register("/sw.js").then(() => navigator.serviceWorker.ready), + new Promise((_, rej) => setTimeout(() => rej(new Error("Service Worker 등록이 10초 안에 완료되지 않았습니다.")), 10000)), + ]); + } catch (e) { + throw new Error(`Service Worker 준비 실패: ${(e as Error).message}`); + } + + // 2) 기존 구독 있으면 그대로 활용 let sub = await reg.pushManager.getSubscription(); + + // 3) 없으면 VAPID 공개키 받아서 새로 구독 if (!sub) { const res = await fetch("/api/m/push/vapid"); - if (!res.ok) return null; + if (!res.ok) throw new Error(`VAPID 키 조회 실패 (HTTP ${res.status}). 로그인 상태를 확인하세요.`); const { publicKey } = await res.json(); - if (!publicKey) return null; + if (!publicKey) throw new Error("서버가 VAPID 공개키를 반환하지 않았습니다."); try { sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource, }); } catch (e) { - console.error("[push-optin] subscribe failed:", e); - return null; + throw new Error(`푸시 구독 실패: ${(e as Error).message ?? e}`); } } - if (sub) await saveSubscriptionToServer(sub).catch(() => {}); + + // 4) 서버에 endpoint 저장 + const sv = await saveSubscriptionToServer(sub); + if (!sv.ok) throw new Error(`서버 저장 실패 (HTTP ${sv.status}${sv.msg ? ` · ${sv.msg}` : ""}).`); return sub; }, [saveSubscriptionToServer]); @@ -82,18 +117,21 @@ export function PushOptIn() { } const intent = readIntent(); try { + // 명시적 register — layout.tsx의 afterInteractive 보다 일찍 도착할 수 있음 + await navigator.serviceWorker.register("/sw.js").catch(() => {}); const reg = await navigator.serviceWorker.ready; const existing = await reg.pushManager.getSubscription(); if (existing) { - // 기존 구독 있으면 ON + 서버 동기화 (endpoint 가 바뀌었을 수도 있으니 다시 저장) await saveSubscriptionToServer(existing).catch(() => {}); setOn(true); writeIntent("on"); return; } - // 구독 없지만 사용자가 이전에 켜뒀고 권한 granted 이면 조용히 재구독 if (intent === "on" && Notification.permission === "granted") { - const sub = await subscribeSilently(); - setOn(!!sub); - if (!sub) writeIntent("off"); + try { + await subscribeSilently(); + setOn(true); + } catch { + setOn(false); writeIntent("off"); + } return; } setOn(false); @@ -106,22 +144,41 @@ export function PushOptIn() { // 켜기 — 권한이 default 면 요청, granted 면 바로 구독. const turnOn = useCallback(async (): Promise => { - const perm = Notification.permission === "granted" - ? "granted" - : await Notification.requestPermission(); - if (perm !== "granted") { - setDenied(perm === "denied"); + try { + const perm = Notification.permission === "granted" + ? "granted" + : await Notification.requestPermission(); + if (perm !== "granted") { + setDenied(perm === "denied"); + writeIntent("off"); + await Swal.fire({ + icon: "warning", + title: "알림 권한이 거부되었습니다", + html: `브라우저(또는 앱) 설정에서 알림 허용으로 변경한 뒤 다시 시도하세요.
현재 상태: ${perm}`, + }); + return false; + } + setDenied(false); + await subscribeSilently(); + writeIntent("on"); + setStatusMsg(""); + return true; + } catch (e) { + const msg = (e as Error).message ?? String(e); + console.error("[push-optin] turnOn failed:", e); + setStatusMsg(msg); writeIntent("off"); + await Swal.fire({ + icon: "error", + title: "알림 켜기에 실패했습니다", + html: `
${msg}

권한: ${typeof Notification !== "undefined" ? Notification.permission : "?"} · 보안컨텍스트: ${httpsOK ? "OK" : "NO(HTTPS 필요)"}
`, + }); return false; } - setDenied(false); - const sub = await subscribeSilently(); - if (sub) { writeIntent("on"); return true; } - return false; - }, [subscribeSilently]); + }, [subscribeSilently, httpsOK]); const turnOff = useCallback(async (): Promise => { - writeIntent("off"); // 의도 먼저 기록 — 도중 실패해도 다음 mount 가 자동 재구독 안 함 + writeIntent("off"); try { const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.getSubscription(); @@ -142,39 +199,86 @@ export function PushOptIn() { if (busy) return; setBusy(true); try { - if (on) { await turnOff(); setOn(false); } - else { setOn(await turnOn()); } + if (on) { + await turnOff(); + setOn(false); + } else { + const ok = await turnOn(); + setOn(ok); + } } finally { setBusy(false); } }; - if (!supported) return null; + // === 작은 헤더 토글 === + if (!supported || !httpsOK) { + if (variant !== "card") return null; + return ( +
+ 이 브라우저/환경에서는 푸시 알림을 지원하지 않습니다. + {!httpsOK && (HTTPS 필요)} +
+ ); + } + if (variant === "compact") { + return ( +
+ + {on ? : } + 알림 {on ? "켜짐" : "꺼짐"} + + + {denied && 설정에서 허용 필요} +
+ ); + } + + // === card 변형 (회원정보 페이지) === return ( -
- - {on ? : } - 알림 {on ? "켜짐" : "꺼짐"} - - - {denied && 설정에서 허용 필요} +
+

+ 푸시 알림 +

+

+ 새 상품 등록/공지를 휴대폰에 즉시 받아볼 수 있습니다. 켜기를 누르면 알림 권한을 요청합니다.
+ 앱(모모유통)에서 켜면 앱 이름으로, 브라우저에서 켜면 브라우저 이름으로 알림이 표시됩니다. +

+
+ + {on ? : } + 알림 {on ? "켜짐" : "꺼짐"} + + + {denied && ( + + 기기/브라우저 설정에서 알림 허용 필요 + + )} + {busy && 처리 중…} +
+ {statusMsg && ( +
+ 마지막 시도 결과: {statusMsg} +
+ )} +
+ 진단 정보 · 권한: {typeof Notification !== "undefined" ? Notification.permission : "?"} · 보안 컨텍스트: {httpsOK ? "OK" : "NO"} · SW: {"serviceWorker" in (typeof navigator !== "undefined" ? navigator : ({} as Navigator)) ? "지원" : "미지원"} +
); }