From 93d6f0fc3ff34a586a46904b559c4ae09eb4a4d3 Mon Sep 17 00:00:00 2001 From: chpark Date: Fri, 29 May 2026 11:12:43 +0900 Subject: [PATCH] =?UTF-8?q?fix(push-optin):=20=EC=83=88=EB=A1=9C=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20OFF=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EC=95=84=EA=B0=80=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - localStorage('momo-push-intent') 로 사용자 의도(켜기/끄기) 영속화. - 마운트 시: pushManager 가 sub 를 갖고 있으면 ON + 서버에 endpoint 재동기화. sub 가 없는데 의도='on' + 권한=granted 면 조용히 재구독해 ON 유지. - SW 업데이트(v1→v2) 직후 getSubscription 이 일시적으로 null 을 반환해 토글이 잘못 OFF 표시되던 케이스 방지. - turnOff 는 의도를 먼저 'off' 로 기록해서 도중 실패해도 자동 재구독 안 함. --- src/components/push-optin.tsx | 144 ++++++++++++++++++++++++---------- 1 file changed, 104 insertions(+), 40 deletions(-) diff --git a/src/components/push-optin.tsx b/src/components/push-optin.tsx index 985deaf..938d3bc 100644 --- a/src/components/push-optin.tsx +++ b/src/components/push-optin.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Bell, BellOff } from "lucide-react"; // VAPID 공개키(base64url) → Uint8Array @@ -13,11 +13,24 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array { return arr; } +// 사용자 의도 기억 — pushManager 가 SW 갱신/브라우저 quirks 로 일시적으로 sub 를 잃어도 +// 권한이 granted 상태면 mount 시 자동 재구독해 'on' 상태를 유지. +const INTENT_KEY = "momo-push-intent"; +const readIntent = (): "on" | "off" | null => { + try { return (typeof localStorage !== "undefined" ? localStorage.getItem(INTENT_KEY) : null) as "on" | "off" | null; } + catch { return null; } +}; +const writeIntent = (v: "on" | "off" | null) => { + try { if (v) localStorage.setItem(INTENT_KEY, v); else localStorage.removeItem(INTENT_KEY); } + catch { /* ignore */ } +}; + // 새 품목 판매 알림 켜기/끄기 스위치. (PWA 설치 + 알림 권한 필요) export function PushOptIn() { const [on, setOn] = useState(false); const [busy, setBusy] = useState(false); const [denied, setDenied] = useState(false); + const bootRef = useRef(false); const supported = typeof window !== "undefined" && @@ -25,52 +38,103 @@ export function PushOptIn() { "PushManager" in window && "Notification" in window; - // 현재 구독 상태를 스위치에 반영 - useEffect(() => { - if (!supported) return; - if (Notification.permission === "denied") { setDenied(true); setOn(false); return; } - navigator.serviceWorker.ready - .then((reg) => reg.pushManager.getSubscription()) - .then((sub) => setOn(!!sub)) - .catch(() => {}); - }, [supported]); - - const turnOn = useCallback(async (): Promise => { - const perm = Notification.permission === "granted" - ? "granted" - : await Notification.requestPermission(); - if (perm !== "granted") { setDenied(perm === "denied"); return false; } - setDenied(false); - const reg = await navigator.serviceWorker.ready; - const res = await fetch("/api/m/push/vapid"); - if (!res.ok) return false; - const { publicKey } = await res.json(); - if (!publicKey) return false; - let sub = await reg.pushManager.getSubscription(); - if (!sub) { - sub = await reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource, - }); - } - const save = await fetch("/api/m/push/subscribe", { + // 서버에 현재 구독 endpoint 저장 — 의도 'on' 일 때만 호출. + const saveSubscriptionToServer = useCallback(async (sub: PushSubscription): Promise => { + 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 save.ok; + return r.ok; }, []); - const turnOff = useCallback(async (): Promise => { + // 권한 granted 전제로 silent 하게 (재)구독. 권한이 default 이면 호출 측에서 먼저 요청. + const subscribeSilently = useCallback(async (): Promise => { + if (Notification.permission !== "granted") return null; const reg = await navigator.serviceWorker.ready; - const sub = await reg.pushManager.getSubscription(); - if (sub) { - await fetch("/api/m/push/unsubscribe", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ endpoint: sub.endpoint }), - }).catch(() => {}); - await sub.unsubscribe().catch(() => {}); + let sub = await reg.pushManager.getSubscription(); + if (!sub) { + const res = await fetch("/api/m/push/vapid"); + if (!res.ok) return null; + const { publicKey } = await res.json(); + if (!publicKey) return null; + try { + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource, + }); + } catch (e) { + console.error("[push-optin] subscribe failed:", e); + return null; + } + } + if (sub) await saveSubscriptionToServer(sub).catch(() => {}); + return sub; + }, [saveSubscriptionToServer]); + + // 마운트 시 상태 동기화 + useEffect(() => { + if (!supported || bootRef.current) return; + bootRef.current = true; + (async () => { + if (Notification.permission === "denied") { + setDenied(true); setOn(false); writeIntent("off"); return; + } + const intent = readIntent(); + try { + 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"); + return; + } + setOn(false); + } catch (e) { + console.error("[push-optin] mount sync error:", e); + setOn(false); + } + })(); + }, [supported, saveSubscriptionToServer, subscribeSilently]); + + // 켜기 — 권한이 default 면 요청, granted 면 바로 구독. + const turnOn = useCallback(async (): Promise => { + const perm = Notification.permission === "granted" + ? "granted" + : await Notification.requestPermission(); + if (perm !== "granted") { + setDenied(perm === "denied"); + writeIntent("off"); + return false; + } + setDenied(false); + const sub = await subscribeSilently(); + if (sub) { writeIntent("on"); return true; } + return false; + }, [subscribeSilently]); + + const turnOff = useCallback(async (): Promise => { + writeIntent("off"); // 의도 먼저 기록 — 도중 실패해도 다음 mount 가 자동 재구독 안 함 + try { + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.getSubscription(); + if (sub) { + await fetch("/api/m/push/unsubscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint: sub.endpoint }), + }).catch(() => {}); + await sub.unsubscribe().catch(() => {}); + } + } catch (e) { + console.error("[push-optin] turnOff error:", e); } }, []);