fix(push-optin): 회원정보 페이지로 위치 이동 + 실패 사유 명확 노출
Deploy momo-erp / deploy (push) Failing after 13m37s

요청 사항:
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 <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-30 16:13:58 +09:00
parent 9bd81d5fbc
commit 4933655c26
3 changed files with 167 additions and 62 deletions
+4 -7
View File
@@ -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() {
</div>
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
<div className="flex items-start justify-between gap-2">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 text-xs sm:text-sm mt-1"> [ ] .</p>
</div>
<div className="shrink-0 pt-1"><PushOptIn /></div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 text-xs sm:text-sm mt-1"> [ ] .</p>
<p className="text-slate-400 text-[11px] mt-0.5"> / <b></b> .</p>
</div>
{isAdmin && onBehalfOfCustomer && (
+4
View File
@@ -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() {
</button>
</div>
{/* 푸시 알림 토글 — 헤더/주문 페이지에서 옮겨옴 (정확한 진단 정보 + 에러 메시지 노출) */}
<PushOptIn variant="card" />
{/* 기본 정보 */}
<form onSubmit={onSaveInfo} className="bg-white border border-slate-200 rounded-xl p-6 space-y-4">
<h3 className="font-bold text-slate-700 mb-3 border-b pb-2"> </h3>
+159 -55
View File
@@ -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<string>("");
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<boolean> => {
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<PushSubscription | null> => {
if (Notification.permission !== "granted") return null;
const reg = await navigator.serviceWorker.ready;
// 실패 시 정확한 사유를 throw — UI 가 사용자에게 보여줄 수 있도록.
const subscribeSilently = useCallback(async (): Promise<PushSubscription> => {
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<never>((_, 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<boolean> => {
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: `브라우저(또는 앱) 설정에서 <b>알림 허용</b>으로 변경한 뒤 다시 시도하세요.<br/><span style="color:#94a3b8;font-size:12px">현재 상태: ${perm}</span>`,
});
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: `<div style="text-align:left">${msg}<br/><br/><span style="color:#94a3b8;font-size:12px">권한: ${typeof Notification !== "undefined" ? Notification.permission : "?"} · 보안컨텍스트: ${httpsOK ? "OK" : "NO(HTTPS 필요)"}</span></div>`,
});
return false;
}
setDenied(false);
const sub = await subscribeSilently();
if (sub) { writeIntent("on"); return true; }
return false;
}, [subscribeSilently]);
}, [subscribeSilently, httpsOK]);
const turnOff = useCallback(async (): Promise<void> => {
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 (
<div className="flex items-center gap-2 text-xs text-slate-500">
<AlertCircle size={14} /> / .
{!httpsOK && <span className="text-rose-500">(HTTPS )</span>}
</div>
);
}
if (variant === "compact") {
return (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 text-xs font-semibold text-slate-600">
{on ? <Bell size={14} className="text-emerald-700" /> : <BellOff size={14} className="text-slate-400" />}
{on ? "켜짐" : "꺼짐"}
</span>
<button
type="button" role="switch" aria-checked={on}
onClick={toggle} disabled={busy || denied}
title={denied ? "기기/브라우저 설정에서 알림을 허용해주세요." : on ? "알림 끄기" : "알림 켜기"}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:opacity-50 ${on ? "bg-emerald-600" : "bg-slate-300"}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${on ? "translate-x-[22px]" : "translate-x-0.5"}`} />
</button>
{denied && <span className="text-[11px] text-rose-500"> </span>}
</div>
);
}
// === card 변형 (회원정보 페이지) ===
return (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 text-xs font-semibold text-slate-600">
{on ? <Bell size={14} className="text-emerald-700" /> : <BellOff size={14} className="text-slate-400" />}
{on ? "켜짐" : "꺼짐"}
</span>
<button
type="button"
role="switch"
aria-checked={on}
onClick={toggle}
disabled={busy || denied}
title={denied ? "기기/브라우저 설정에서 알림을 허용해주세요." : on ? "알림 끄기" : "알림 켜기"}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:opacity-50 ${
on ? "bg-emerald-600" : "bg-slate-300"
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
on ? "translate-x-[22px]" : "translate-x-0.5"
}`}
/>
</button>
{denied && <span className="text-[11px] text-rose-500"> </span>}
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-3">
<h3 className="font-bold text-slate-700 mb-3 border-b pb-2 inline-flex items-center gap-2">
<Bell size={16} className="text-emerald-700" />
</h3>
<p className="text-xs text-slate-500 leading-relaxed">
/ . .<br />
<span className="text-slate-400">() , .</span>
</p>
<div className="flex items-center gap-3 pt-2">
<span className="inline-flex items-center gap-1.5 text-sm font-semibold">
{on ? <Bell size={16} className="text-emerald-700" /> : <BellOff size={16} className="text-slate-400" />}
<span className={on ? "text-emerald-700" : "text-slate-500"}>{on ? "켜짐" : "꺼짐"}</span>
</span>
<button
type="button" role="switch" aria-checked={on}
onClick={toggle} disabled={busy || denied}
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-full transition-colors disabled:opacity-50 ${on ? "bg-emerald-600" : "bg-slate-300"}`}
>
<span className={`inline-block h-6 w-6 transform rounded-full bg-white shadow transition-transform ${on ? "translate-x-[22px]" : "translate-x-0.5"}`} />
</button>
{denied && (
<span className="text-[11px] text-rose-500 inline-flex items-center gap-1">
<AlertCircle size={12} /> /
</span>
)}
{busy && <span className="text-[11px] text-slate-400"> </span>}
</div>
{statusMsg && (
<div className="text-[11px] text-rose-600 bg-rose-50 border border-rose-100 rounded p-2 leading-snug">
: {statusMsg}
</div>
)}
<div className="text-[10px] text-slate-400 leading-snug pt-1">
· : {typeof Notification !== "undefined" ? Notification.permission : "?"} · : {httpsOK ? "OK" : "NO"} · SW: {"serviceWorker" in (typeof navigator !== "undefined" ? navigator : ({} as Navigator)) ? "지원" : "미지원"}
</div>
</div>
);
}