From fef53fd0031d5e41561489d2623b76136834bf76 Mon Sep 17 00:00:00 2001 From: Arda Samadi Date: Wed, 17 Jun 2026 14:02:56 +0330 Subject: [PATCH] feat(notifications): web push opt-in + unread nav badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces three events without a full notification center: - service worker (public/sw.js) gains push + notificationclick handlers (RTL, focuses an existing tab or opens the target URL). - usePushNotifications hook: permission + VAPID subscribe/unsubscribe against the backend push endpoints. - opt-in UI both ways: a one-time dismissible post-login banner (PushPermissionBanner, rendered in MyLayout) and a permanent toggle (PushSettingToggle) on buyer profile settings and seller store settings. - useBadgeCounts polls /api/notif/v1/badges/; small red badges on the bottom-nav profile icon (chat + order updates), the profile sidebar (پیام‌ها / سفارش‌ها) and the seller dashboard sidebar. Visiting orders clears its badge. Degrades gracefully: if the backend endpoints are absent the counts fall back to zero and nothing throws. Co-Authored-By: Claude Opus 4.8 --- app/components/MyLayout.tsx | 19 ++- .../notifications/PushPermissionBanner.tsx | 67 +++++++++ .../notifications/PushSettingToggle.tsx | 50 +++++++ app/hooks/useBadgeCounts.ts | 67 +++++++++ app/hooks/usePushNotifications.ts | 129 ++++++++++++++++++ app/routes/profile.orders.tsx | 9 ++ app/routes/profile.setting.tsx | 2 + app/routes/profile.tsx | 44 +++--- app/routes/store.$storeId.setting.tsx | 6 +- app/routes/store.$storeId.tsx | 21 ++- public/sw.js | 52 +++++++ 11 files changed, 442 insertions(+), 24 deletions(-) create mode 100644 app/components/notifications/PushPermissionBanner.tsx create mode 100644 app/components/notifications/PushSettingToggle.tsx create mode 100644 app/hooks/useBadgeCounts.ts create mode 100644 app/hooks/usePushNotifications.ts diff --git a/app/components/MyLayout.tsx b/app/components/MyLayout.tsx index 5c1ae0d..fedc7aa 100644 --- a/app/components/MyLayout.tsx +++ b/app/components/MyLayout.tsx @@ -14,6 +14,8 @@ import { detectPage } from "~/utils/helpers"; import { useRootData } from "~/hooks/use-root-data"; import { useCartCount } from "~/requestHandler/use-cart-hooks"; import { useSellerOrderCount } from "~/requestHandler/use-order-hooks"; +import { useBadgeCounts } from "~/hooks/useBadgeCounts"; +import { PushPermissionBanner } from "~/components/notifications/PushPermissionBanner"; import settingIconFilled from "~/assets/icons/navbar/setting-filled.svg"; import settingIconOutline from "~/assets/icons/navbar/setting-outline.svg"; import documentFilled from "~/assets/icons/navbar/document-filled.svg"; @@ -73,6 +75,7 @@ const MyLayout = React.memo(({ children }: { children: React.ReactNode }) => { ) : ( )} + {isDesktopReady && } @@ -97,6 +100,11 @@ const MobileBottomNavigation = ({ const { user } = useRootData(); const [isStoreMode, setIsStoreMode] = useState(false); const sellerOrderCount = useSellerOrderCount(!!isStoreMode); + const { data: badgeCounts } = useBadgeCounts(); + // Buyer-facing account activity (unread chats + order status changes) is + // surfaced on the profile icon, since both live under /profile. + const accountUnread = + (badgeCounts?.chat || 0) + (badgeCounts?.order_updates || 0); const [storeId, setStoreId] = useState(null); // Initialize state from sessionStorage on mount - only once @@ -303,6 +311,8 @@ const MobileBottomNavigation = ({ : theme === "dark" ? profileFilled : profileOutline, + badge: + accountUnread > 0 && user?.access_token ? accountUnread : null, }, { link: "/cart", @@ -344,6 +354,7 @@ const MobileBottomNavigation = ({ currentPage, cartCount, sellerOrderCount, + accountUnread, user?.access_token, isStoreMode, storeId, @@ -374,11 +385,11 @@ const MobileBottomNavigation = ({ ) : ( item.icon )} - {item.badge && ( - - {item.badge} + {item.badge ? ( + + {item.badge > 99 ? "99+" : item.badge} - )} + ) : null} ))} diff --git a/app/components/notifications/PushPermissionBanner.tsx b/app/components/notifications/PushPermissionBanner.tsx new file mode 100644 index 0000000..3be35da --- /dev/null +++ b/app/components/notifications/PushPermissionBanner.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import { Bell, X } from "lucide-react"; +import { useRootData } from "~/hooks/use-root-data"; +import { usePushNotifications } from "~/hooks/usePushNotifications"; + +const DISMISS_KEY = "push_banner_dismissed"; + +/** + * One-time, dismissible bar prompting logged-in users to enable web push. + * Shown only when: logged in, push is supported, permission not yet decided, + * and the user hasn't dismissed or already subscribed. A permanent toggle in + * settings lets them change their mind later. + */ +export function PushPermissionBanner() { + const { user } = useRootData(); + const { supported, permission, isSubscribed, subscribe, busy } = + usePushNotifications(); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (typeof window === "undefined") return; + const dismissed = localStorage.getItem(DISMISS_KEY) === "1"; + setVisible( + !!user?.access_token && + supported && + permission === "default" && + !isSubscribed && + !dismissed + ); + }, [user?.access_token, supported, permission, isSubscribed]); + + if (!visible) return null; + + const dismiss = () => { + localStorage.setItem(DISMISS_KEY, "1"); + setVisible(false); + }; + + const enable = async () => { + const ok = await subscribe(); + // Whether granted or denied, don't nag again from the banner. + localStorage.setItem(DISMISS_KEY, "1"); + setVisible(false); + return ok; + }; + + return ( +
+
+ +

+ اعلان‌ها را فعال کنید تا از پیام‌ها و وضعیت سفارش‌ها باخبر شوید. +

+ + +
+
+ ); +} diff --git a/app/components/notifications/PushSettingToggle.tsx b/app/components/notifications/PushSettingToggle.tsx new file mode 100644 index 0000000..12ecff3 --- /dev/null +++ b/app/components/notifications/PushSettingToggle.tsx @@ -0,0 +1,50 @@ +import { usePushNotifications } from "~/hooks/usePushNotifications"; + +/** + * A settings row with a switch to enable/disable web push notifications. + * Mirrors the existing theme-toggle row styling on the settings pages. + */ +export function PushSettingToggle({ className = "" }: { className?: string }) { + const { supported, permission, isSubscribed, subscribe, disable, busy } = + usePushNotifications(); + + const blocked = permission === "denied"; + const checked = isSubscribed && permission === "granted"; + + const onToggle = () => { + if (busy || blocked) return; + if (checked) { + disable(); + } else { + subscribe(); + } + }; + + return ( +
+
+
+

اعلان‌ها

+

+ {blocked + ? "اعلان‌ها در مرورگر مسدود شده‌اند. از تنظیمات مرورگر اجازه دهید." + : !supported + ? "مرورگر شما از اعلان پشتیبانی نمی‌کند." + : "دریافت اعلان برای پیام‌ها و وضعیت سفارش‌ها"} +

+
+ +
+
+ ); +} diff --git a/app/hooks/useBadgeCounts.ts b/app/hooks/useBadgeCounts.ts new file mode 100644 index 0000000..a14f18d --- /dev/null +++ b/app/hooks/useBadgeCounts.ts @@ -0,0 +1,67 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAuthToken } from "./use-root-data"; +import { getApiBaseUrl } from "~/utils/api-config"; + +export interface BadgeCounts { + chat: number; + order_updates: number; + seller_orders: number; +} + +const EMPTY: BadgeCounts = { chat: 0, order_updates: 0, seller_orders: 0 }; + +export const badgeKeys = { + all: ["notif-badges"] as const, +}; + +/** + * Polls unread counts for the nav badges (chat / buyer order updates / seller + * new orders). Lightweight: a single endpoint, refetched on an interval and + * when the tab regains focus. + */ +export function useBadgeCounts() { + const token = useAuthToken(); + const apiBase = getApiBaseUrl(); + + return useQuery({ + queryKey: badgeKeys.all, + enabled: !!token, + refetchInterval: 30000, + refetchOnWindowFocus: true, + queryFn: async (): Promise => { + if (!token) return EMPTY; + const res = await fetch(`${apiBase}/api/notif/v1/badges/`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return EMPTY; + return (await res.json()) as BadgeCounts; + }, + }); +} + +/** + * Marks a badge category ("order_updates" | "seller_orders") as read, then + * refreshes the counts. Chat clears via the existing per-thread read flow. + */ +export function useMarkBadgeRead() { + const token = useAuthToken(); + const apiBase = getApiBaseUrl(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (category: "order_updates" | "seller_orders") => { + if (!token) return; + await fetch(`${apiBase}/api/notif/v1/badges/mark-read/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ category }), + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: badgeKeys.all }); + }, + }); +} diff --git a/app/hooks/usePushNotifications.ts b/app/hooks/usePushNotifications.ts new file mode 100644 index 0000000..a572331 --- /dev/null +++ b/app/hooks/usePushNotifications.ts @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useState } from "react"; +import { useAuthToken } from "./use-root-data"; +import { getApiBaseUrl } from "~/utils/api-config"; + +/** + * Web Push subscription management. + * + * Flow: ask permission -> fetch VAPID public key -> subscribe via the service + * worker's PushManager -> POST the subscription to the backend. Disabling + * unsubscribes locally and tells the backend to drop the row. + */ + +type PermissionState = NotificationPermission | "unsupported"; + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = atob(base64); + // Back the array with a concrete ArrayBuffer so it satisfies BufferSource. + const outputArray = new Uint8Array(new ArrayBuffer(rawData.length)); + for (let i = 0; i < rawData.length; i++) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +function isPushSupported(): boolean { + return ( + typeof window !== "undefined" && + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window + ); +} + +export function usePushNotifications() { + const token = useAuthToken(); + const apiBase = getApiBaseUrl(); + + const [permission, setPermission] = useState("default"); + const [isSubscribed, setIsSubscribed] = useState(false); + const [busy, setBusy] = useState(false); + + // Reflect current permission + subscription state on mount. + useEffect(() => { + if (!isPushSupported()) { + setPermission("unsupported"); + return; + } + setPermission(Notification.permission); + navigator.serviceWorker.ready + .then((reg) => reg.pushManager.getSubscription()) + .then((sub) => setIsSubscribed(!!sub)) + .catch(() => setIsSubscribed(false)); + }, []); + + const authHeaders = useCallback(() => { + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + return headers; + }, [token]); + + const subscribe = useCallback(async (): Promise => { + if (!isPushSupported() || !token) return false; + setBusy(true); + try { + const perm = await Notification.requestPermission(); + setPermission(perm); + if (perm !== "granted") return false; + + // Get the VAPID public key from the backend. + const keyRes = await fetch(`${apiBase}/api/notif/v1/push/vapid-public-key/`); + const { public_key: publicKey } = await keyRes.json(); + if (!publicKey) return false; + + const reg = await navigator.serviceWorker.ready; + let sub = await reg.pushManager.getSubscription(); + if (!sub) { + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + } + + const res = await fetch(`${apiBase}/api/notif/v1/push/subscribe/`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(sub.toJSON()), + }); + const ok = res.ok; + setIsSubscribed(ok); + return ok; + } catch (e) { + console.error("Push subscribe failed:", e); + return false; + } finally { + setBusy(false); + } + }, [apiBase, token, authHeaders]); + + const disable = useCallback(async (): Promise => { + if (!isPushSupported()) return; + setBusy(true); + try { + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.getSubscription(); + if (sub) { + await fetch(`${apiBase}/api/notif/v1/push/unsubscribe/`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ endpoint: sub.endpoint }), + }).catch(() => {}); + await sub.unsubscribe().catch(() => {}); + } + setIsSubscribed(false); + } finally { + setBusy(false); + } + }, [apiBase, authHeaders]); + + return { + supported: permission !== "unsupported", + permission, + isSubscribed, + busy, + subscribe, + disable, + }; +} diff --git a/app/routes/profile.orders.tsx b/app/routes/profile.orders.tsx index 8d7a404..8ad87e9 100644 --- a/app/routes/profile.orders.tsx +++ b/app/routes/profile.orders.tsx @@ -27,6 +27,7 @@ import UiProvider from "~/components/UiProvider"; import { isVideo } from "~/components/MondrianProductList"; import SellerLogo from "~/components/SellerLogo"; import { useRootData } from "~/hooks/use-root-data"; +import { useMarkBadgeRead } from "~/hooks/useBadgeCounts"; // Product Modal Component const ProductModal = ({ product, @@ -79,6 +80,7 @@ const ProductModal = ({ export default function Orders() { const { data, isLoading, isError, error, refetch } = useGetUserOrders(); + const markBadgeRead = useMarkBadgeRead(); const [currentData, setCurrentData] = useState([]); const [activeTab, setActiveTab] = useState(0); const [activeFactorId, setActiveFactorId] = useState(""); @@ -86,6 +88,13 @@ export default function Orders() { const [showModal, setShowModal] = useState(false); const [selectedProduct, setSelectedProduct] = useState(null); + + // Visiting the orders page clears the "order status changed" badge. + useEffect(() => { + markBadgeRead.mutate("order_updates"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Update currentData when data or activeTab changes useEffect(() => { if (data) { diff --git a/app/routes/profile.setting.tsx b/app/routes/profile.setting.tsx index 1632506..5f7a26a 100644 --- a/app/routes/profile.setting.tsx +++ b/app/routes/profile.setting.tsx @@ -13,6 +13,7 @@ import { import { useToast } from "~/hooks/use-toast"; import { Loader2, Mail, Phone, User } from "lucide-react"; import { UserProfileUpdateSexEnum } from "src/api/types"; +import { PushSettingToggle } from "~/components/notifications/PushSettingToggle"; export default function Setting() { const { data: dataProfile, isFetching: isFetchingProfile } = @@ -115,6 +116,7 @@ export default function Setting() { inputOnChange={() => {}} disabled={true} /> +
diff --git a/app/routes/profile.tsx b/app/routes/profile.tsx index b736164..9d77ce0 100644 --- a/app/routes/profile.tsx +++ b/app/routes/profile.tsx @@ -13,10 +13,11 @@ import { } from "lucide-react"; import { useServiceGetProfile } from "~/utils/RequestHandler"; import { useRootData } from "~/hooks/use-root-data"; +import { useBadgeCounts } from "~/hooks/useBadgeCounts"; const NAV = [ - { label: "سفارش‌ها", to: "/profile/orders", icon: ShoppingBag }, - { label: "پیام‌ها", to: "/profile/threads", icon: MessageCircle }, + { label: "سفارش‌ها", to: "/profile/orders", icon: ShoppingBag, badgeKey: "order_updates" as const }, + { label: "پیام‌ها", to: "/profile/threads", icon: MessageCircle, badgeKey: "chat" as const }, { label: "دنبال‌شده‌ها", to: "/profile/following", icon: CircleFadingPlus }, { label: "ذخیره‌شده‌ها", to: "/profile/bookmarks", icon: BookmarkMinus }, { label: "ست‌های من", to: "/profile/sets", icon: Layers }, @@ -34,6 +35,7 @@ export default function ProfileLayout() { const location = useLocation(); const { user } = useRootData(); const { data: profile } = useServiceGetProfile(); + const { data: badges } = useBadgeCounts(); const isActive = (to: string) => location.pathname === to || location.pathname.startsWith(to + "/"); @@ -56,21 +58,29 @@ export default function ProfileLayout() { ) : null}