feat(notifications): web push opt-in + unread nav badges

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 <noreply@anthropic.com>
This commit is contained in:
Arda Samadi 2026-06-17 14:02:56 +03:30
parent 257871d5f1
commit fef53fd003
11 changed files with 442 additions and 24 deletions

View File

@ -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 }) => {
) : (
<MobileBottomNavigation hideOnDesktop={expandOnDesktop} />
)}
<PushPermissionBanner />
</div>
</div>
{isDesktopReady && <DesktopFooter />}
@ -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<string | null>(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 && (
<span className="absolute -top-1 -right-1 text-R12 ">
{item.badge}
{item.badge ? (
<span className="absolute -top-1.5 -right-1.5 min-w-[16px] h-4 px-1 rounded-full bg-RED text-white text-[10px] font-bold flex items-center justify-center leading-none">
{item.badge > 99 ? "99+" : item.badge}
</span>
)}
) : null}
</Link>
))}
</div>

View File

@ -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 (
<div className="fixed bottom-[calc(58px+env(safe-area-inset-bottom))] lg:bottom-4 left-1/2 -translate-x-1/2 z-40 w-[92%] max-w-md lg:max-w-sm">
<div className="flex items-center gap-3 rounded-2xl bg-BLACK text-white shadow-lg px-4 py-3">
<Bell size={20} className="shrink-0" />
<p className="text-[13px] leading-5 flex-1">
اعلانها را فعال کنید تا از پیامها و وضعیت سفارشها باخبر شوید.
</p>
<button
onClick={enable}
disabled={busy}
className="shrink-0 rounded-xl bg-VITROWN_BLUE px-3 py-1.5 text-[13px] font-bold disabled:opacity-60"
>
{busy ? "..." : "فعال‌سازی"}
</button>
<button onClick={dismiss} aria-label="بستن" className="shrink-0 opacity-70">
<X size={18} />
</button>
</div>
</div>
);
}

View File

@ -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 (
<div className={className}>
<div className="flex w-full items-center justify-between">
<div className="flex flex-col gap-1">
<p className="text-R12 lg:text-B16 font-bold">اعلانها</p>
<p className="text-R10 lg:text-R12 text-GRAY">
{blocked
? "اعلان‌ها در مرورگر مسدود شده‌اند. از تنظیمات مرورگر اجازه دهید."
: !supported
? "مرورگر شما از اعلان پشتیبانی نمی‌کند."
: "دریافت اعلان برای پیام‌ها و وضعیت سفارش‌ها"}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
disabled={busy || blocked || !supported}
className="sr-only peer"
aria-label="اعلان‌ها"
/>
<div className="w-11 h-6 bg-WHITE2 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-WHITE after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-WHITE after:border-GRAY3 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-VITROWN_BLUE border peer-disabled:opacity-50"></div>
</label>
</div>
</div>
);
}

View File

@ -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<BadgeCounts> => {
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 });
},
});
}

View File

@ -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<ArrayBuffer> {
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<PermissionState>("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<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}, [token]);
const subscribe = useCallback(async (): Promise<boolean> => {
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<void> => {
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,
};
}

View File

@ -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<OrderList[]>([]);
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<OrderItemSummary | null>(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) {

View File

@ -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}
/>
<PushSettingToggle className="border border-WHITE3 rounded-2xl p-4" />
</div>
<Divider className={"my-10"} />
<div className="flex flex-col gap-12 w-full px-5">

View File

@ -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,7 +58,9 @@ export default function ProfileLayout() {
) : null}
</div>
<nav className="p-2.5 flex flex-col gap-0.5">
{NAV.map((n) => (
{NAV.map((n) => {
const count = n.badgeKey ? badges?.[n.badgeKey] || 0 : 0;
return (
<Link
key={n.to}
to={n.to}
@ -69,8 +73,14 @@ export default function ProfileLayout() {
>
<n.icon size={20} />
{n.label}
{count > 0 ? (
<span className="ml-auto min-w-[18px] h-[18px] px-1 rounded-full bg-RED text-white text-[10px] font-bold flex items-center justify-center leading-none">
{count > 99 ? "99+" : count}
</span>
) : null}
</Link>
))}
);
})}
{user?.access_token ? (
<Link
to="/logout"

View File

@ -18,6 +18,7 @@ import { useStoreOwnership } from "../hooks/useStoreOwnership";
import { useState } from "react";
import { SunMoonToggle } from "~/components/SunMoonToggle";
import { useRootData } from "~/hooks/use-root-data";
import { PushSettingToggle } from "~/components/notifications/PushSettingToggle";
// Import the InstagramSyncDrawer component
import { InstagramSyncDrawer } from "./store.$storeId._index";
@ -149,7 +150,7 @@ export default function StoreSetting() {
</h2>
{/* Status + Theme cards */}
<div className="lg:grid lg:grid-cols-2 lg:gap-4 lg:mb-6">
<div className="lg:grid lg:grid-cols-3 lg:gap-4 lg:mb-6">
{/* Store Status */}
<div className="p-4 bg-WHITE border-b border-inner-border lg:border lg:border-WHITE3 lg:rounded-2xl lg:p-6">
<div className="flex w-full items-center justify-between">
@ -190,6 +191,9 @@ export default function StoreSetting() {
<SunMoonToggle theme={initialTheme} />
</div>
</div>
{/* Push notifications */}
<PushSettingToggle className="p-4 bg-WHITE border-b border-inner-border lg:border lg:border-WHITE3 lg:rounded-2xl lg:p-6" />
</div>
{/* Menu Items */}

View File

@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { useSellerData } from "~/requestHandler/use-seller-hooks";
import SellerLogo from "~/components/SellerLogo";
import { useBadgeCounts } from "~/hooks/useBadgeCounts";
/**
* Desktop layout for the seller dashboard. Renders a sticky admin sidebar
@ -21,15 +22,26 @@ export default function StoreLayout() {
const { storeId } = useParams();
const location = useLocation();
const { data } = useSellerData(storeId);
const { data: badges } = useBadgeCounts();
const base = `/store/${storeId}`;
const NAV = [
{ label: "فروشگاه", to: base, icon: LayoutGrid, exact: true },
{ label: "محصولات", to: `${base}/products`, icon: Package },
{ label: "سفارش‌ها", to: `${base}/orders`, icon: ClipboardList },
{
label: "سفارش‌ها",
to: `${base}/orders`,
icon: ClipboardList,
badge: badges?.seller_orders || 0,
},
{ label: "مالی", to: `${base}/financial-dashboard`, icon: Wallet },
{ label: "روش ارسال", to: `${base}/shipping-method`, icon: Truck },
{ label: "پیام‌ها", to: `${base}/threads`, icon: MessageCircle },
{
label: "پیام‌ها",
to: `${base}/threads`,
icon: MessageCircle,
badge: badges?.chat || 0,
},
{ label: "تنظیمات", to: `${base}/setting`, icon: Settings },
];
@ -65,6 +77,11 @@ export default function StoreLayout() {
>
<n.icon size={20} />
{n.label}
{n.badge && n.badge > 0 ? (
<span className="ml-auto min-w-[18px] h-[18px] px-1 rounded-full bg-RED text-white text-[10px] font-bold flex items-center justify-center leading-none">
{n.badge > 99 ? "99+" : n.badge}
</span>
) : null}
</Link>
))}
<Link

View File

@ -142,3 +142,55 @@ self.addEventListener("message", (event) => {
self.skipWaiting();
}
});
// ---- Web Push ----
// Show a notification when a push arrives. Payload is JSON:
// { title, body, url, tag, notification_id }
self.addEventListener("push", (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch (e) {
data = { title: "ویترون", body: event.data ? event.data.text() : "" };
}
const title = data.title || "ویترون";
const options = {
body: data.body || "",
icon: "/icon-192x192.png",
badge: "/icon-96x96.png",
dir: "rtl",
lang: "fa",
tag: data.tag || "vitrown",
renotify: true,
data: { url: data.url || "/", notificationId: data.notification_id },
};
event.waitUntil(self.registration.showNotification(title, options));
});
// Focus an existing tab (or open a new one) on the target URL when clicked.
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const targetUrl = (event.notification.data && event.notification.data.url) || "/";
event.waitUntil(
clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clientList) => {
for (const client of clientList) {
// If a tab is already open, navigate it and focus.
if ("focus" in client) {
client.focus();
if ("navigate" in client) {
client.navigate(targetUrl).catch(() => {});
}
return;
}
}
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
})
);
});