요청 사항: 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:
@@ -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,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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user