- 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:
@@ -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,43 +38,91 @@ 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;
|
||||
}, []);
|
||||
|
||||
// 권한 granted 전제로 silent 하게 (재)구독. 권한이 default 이면 호출 측에서 먼저 요청.
|
||||
const subscribeSilently = useCallback(async (): Promise<PushSubscription | null> => {
|
||||
if (Notification.permission !== "granted") return null;
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
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) {
|
||||
@@ -72,6 +133,9 @@ export function PushOptIn() {
|
||||
}).catch(() => {});
|
||||
await sub.unsubscribe().catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[push-optin] turnOff error:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggle = async () => {
|
||||
|
||||
Reference in New Issue
Block a user