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:
parent
257871d5f1
commit
fef53fd003
@ -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>
|
||||
|
||||
67
app/components/notifications/PushPermissionBanner.tsx
Normal file
67
app/components/notifications/PushPermissionBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
app/components/notifications/PushSettingToggle.tsx
Normal file
50
app/components/notifications/PushSettingToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
app/hooks/useBadgeCounts.ts
Normal file
67
app/hooks/useBadgeCounts.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
129
app/hooks/usePushNotifications.ts
Normal file
129
app/hooks/usePushNotifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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
|
||||
|
||||
52
public/sw.js
52
public/sw.js
@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user