fix(push-optin): 새로고침 시 알림 OFF 되돌아가는 문제 해결
Deploy momo-erp / deploy (push) Successful in 2m2s

- localStorage('momo-push-intent') 로 사용자 의도(켜기/끄기) 영속화.
- 마운트 시: pushManager 가 sub 를 갖고 있으면 ON + 서버에 endpoint 재동기화.
  sub 가 없는데 의도='on' + 권한=granted 면 조용히 재구독해 ON 유지.
- SW 업데이트(v1→v2) 직후 getSubscription 이 일시적으로 null 을 반환해
  토글이 잘못 OFF 표시되던 케이스 방지.
- turnOff 는 의도를 먼저 'off' 로 기록해서 도중 실패해도 자동 재구독 안 함.
This commit is contained in:
chpark
2026-05-29 11:12:43 +09:00
parent cbea0f4b9f
commit 93d6f0fc3f
+104 -40
View File
@@ -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<boolean> => {
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<boolean> => {
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<void> => {
// 권한 granted 전제로 silent 하게 (재)구독. 권한이 default 이면 호출 측에서 먼저 요청.
const subscribeSilently = useCallback(async (): Promise<PushSubscription | null> => {
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<boolean> => {
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<void> => {
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);
}
}, []);