- 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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Bell, BellOff } from "lucide-react";
|
import { Bell, BellOff } from "lucide-react";
|
||||||
|
|
||||||
// VAPID 공개키(base64url) → Uint8Array
|
// VAPID 공개키(base64url) → Uint8Array
|
||||||
@@ -13,11 +13,24 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|||||||
return arr;
|
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 설치 + 알림 권한 필요)
|
// 새 품목 판매 알림 켜기/끄기 스위치. (PWA 설치 + 알림 권한 필요)
|
||||||
export function PushOptIn() {
|
export function PushOptIn() {
|
||||||
const [on, setOn] = useState(false);
|
const [on, setOn] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [denied, setDenied] = useState(false);
|
const [denied, setDenied] = useState(false);
|
||||||
|
const bootRef = useRef(false);
|
||||||
|
|
||||||
const supported =
|
const supported =
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
@@ -25,43 +38,91 @@ export function PushOptIn() {
|
|||||||
"PushManager" in window &&
|
"PushManager" in window &&
|
||||||
"Notification" in window;
|
"Notification" in window;
|
||||||
|
|
||||||
// 현재 구독 상태를 스위치에 반영
|
// 서버에 현재 구독 endpoint 저장 — 의도 'on' 일 때만 호출.
|
||||||
useEffect(() => {
|
const saveSubscriptionToServer = useCallback(async (sub: PushSubscription): Promise<boolean> => {
|
||||||
if (!supported) return;
|
const r = await fetch("/api/m/push/subscribe", {
|
||||||
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", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ subscription: sub.toJSON(), userAgent: navigator.userAgent }),
|
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> => {
|
const turnOff = useCallback(async (): Promise<void> => {
|
||||||
|
writeIntent("off"); // 의도 먼저 기록 — 도중 실패해도 다음 mount 가 자동 재구독 안 함
|
||||||
|
try {
|
||||||
const reg = await navigator.serviceWorker.ready;
|
const reg = await navigator.serviceWorker.ready;
|
||||||
const sub = await reg.pushManager.getSubscription();
|
const sub = await reg.pushManager.getSubscription();
|
||||||
if (sub) {
|
if (sub) {
|
||||||
@@ -72,6 +133,9 @@ export function PushOptIn() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
await sub.unsubscribe().catch(() => {});
|
await sub.unsubscribe().catch(() => {});
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[push-optin] turnOff error:", e);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggle = async () => {
|
const toggle = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user