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 { useRootData } from "~/hooks/use-root-data";
|
||||||
import { useCartCount } from "~/requestHandler/use-cart-hooks";
|
import { useCartCount } from "~/requestHandler/use-cart-hooks";
|
||||||
import { useSellerOrderCount } from "~/requestHandler/use-order-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 settingIconFilled from "~/assets/icons/navbar/setting-filled.svg";
|
||||||
import settingIconOutline from "~/assets/icons/navbar/setting-outline.svg";
|
import settingIconOutline from "~/assets/icons/navbar/setting-outline.svg";
|
||||||
import documentFilled from "~/assets/icons/navbar/document-filled.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} />
|
<MobileBottomNavigation hideOnDesktop={expandOnDesktop} />
|
||||||
)}
|
)}
|
||||||
|
<PushPermissionBanner />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isDesktopReady && <DesktopFooter />}
|
{isDesktopReady && <DesktopFooter />}
|
||||||
@ -97,6 +100,11 @@ const MobileBottomNavigation = ({
|
|||||||
const { user } = useRootData();
|
const { user } = useRootData();
|
||||||
const [isStoreMode, setIsStoreMode] = useState(false);
|
const [isStoreMode, setIsStoreMode] = useState(false);
|
||||||
const sellerOrderCount = useSellerOrderCount(!!isStoreMode);
|
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);
|
const [storeId, setStoreId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Initialize state from sessionStorage on mount - only once
|
// Initialize state from sessionStorage on mount - only once
|
||||||
@ -303,6 +311,8 @@ const MobileBottomNavigation = ({
|
|||||||
: theme === "dark"
|
: theme === "dark"
|
||||||
? profileFilled
|
? profileFilled
|
||||||
: profileOutline,
|
: profileOutline,
|
||||||
|
badge:
|
||||||
|
accountUnread > 0 && user?.access_token ? accountUnread : null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
link: "/cart",
|
link: "/cart",
|
||||||
@ -344,6 +354,7 @@ const MobileBottomNavigation = ({
|
|||||||
currentPage,
|
currentPage,
|
||||||
cartCount,
|
cartCount,
|
||||||
sellerOrderCount,
|
sellerOrderCount,
|
||||||
|
accountUnread,
|
||||||
user?.access_token,
|
user?.access_token,
|
||||||
isStoreMode,
|
isStoreMode,
|
||||||
storeId,
|
storeId,
|
||||||
@ -374,11 +385,11 @@ const MobileBottomNavigation = ({
|
|||||||
) : (
|
) : (
|
||||||
item.icon
|
item.icon
|
||||||
)}
|
)}
|
||||||
{item.badge && (
|
{item.badge ? (
|
||||||
<span className="absolute -top-1 -right-1 text-R12 ">
|
<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}
|
{item.badge > 99 ? "99+" : item.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 { isVideo } from "~/components/MondrianProductList";
|
||||||
import SellerLogo from "~/components/SellerLogo";
|
import SellerLogo from "~/components/SellerLogo";
|
||||||
import { useRootData } from "~/hooks/use-root-data";
|
import { useRootData } from "~/hooks/use-root-data";
|
||||||
|
import { useMarkBadgeRead } from "~/hooks/useBadgeCounts";
|
||||||
// Product Modal Component
|
// Product Modal Component
|
||||||
const ProductModal = ({
|
const ProductModal = ({
|
||||||
product,
|
product,
|
||||||
@ -79,6 +80,7 @@ const ProductModal = ({
|
|||||||
|
|
||||||
export default function Orders() {
|
export default function Orders() {
|
||||||
const { data, isLoading, isError, error, refetch } = useGetUserOrders();
|
const { data, isLoading, isError, error, refetch } = useGetUserOrders();
|
||||||
|
const markBadgeRead = useMarkBadgeRead();
|
||||||
const [currentData, setCurrentData] = useState<OrderList[]>([]);
|
const [currentData, setCurrentData] = useState<OrderList[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState(0);
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
const [activeFactorId, setActiveFactorId] = useState("");
|
const [activeFactorId, setActiveFactorId] = useState("");
|
||||||
@ -86,6 +88,13 @@ export default function Orders() {
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [selectedProduct, setSelectedProduct] =
|
const [selectedProduct, setSelectedProduct] =
|
||||||
useState<OrderItemSummary | null>(null);
|
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
|
// Update currentData when data or activeTab changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
import { useToast } from "~/hooks/use-toast";
|
import { useToast } from "~/hooks/use-toast";
|
||||||
import { Loader2, Mail, Phone, User } from "lucide-react";
|
import { Loader2, Mail, Phone, User } from "lucide-react";
|
||||||
import { UserProfileUpdateSexEnum } from "src/api/types";
|
import { UserProfileUpdateSexEnum } from "src/api/types";
|
||||||
|
import { PushSettingToggle } from "~/components/notifications/PushSettingToggle";
|
||||||
|
|
||||||
export default function Setting() {
|
export default function Setting() {
|
||||||
const { data: dataProfile, isFetching: isFetchingProfile } =
|
const { data: dataProfile, isFetching: isFetchingProfile } =
|
||||||
@ -115,6 +116,7 @@ export default function Setting() {
|
|||||||
inputOnChange={() => {}}
|
inputOnChange={() => {}}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
/>
|
/>
|
||||||
|
<PushSettingToggle className="border border-WHITE3 rounded-2xl p-4" />
|
||||||
</div>
|
</div>
|
||||||
<Divider className={"my-10"} />
|
<Divider className={"my-10"} />
|
||||||
<div className="flex flex-col gap-12 w-full px-5">
|
<div className="flex flex-col gap-12 w-full px-5">
|
||||||
|
|||||||
@ -13,10 +13,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useServiceGetProfile } from "~/utils/RequestHandler";
|
import { useServiceGetProfile } from "~/utils/RequestHandler";
|
||||||
import { useRootData } from "~/hooks/use-root-data";
|
import { useRootData } from "~/hooks/use-root-data";
|
||||||
|
import { useBadgeCounts } from "~/hooks/useBadgeCounts";
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ label: "سفارشها", to: "/profile/orders", icon: ShoppingBag },
|
{ label: "سفارشها", to: "/profile/orders", icon: ShoppingBag, badgeKey: "order_updates" as const },
|
||||||
{ label: "پیامها", to: "/profile/threads", icon: MessageCircle },
|
{ label: "پیامها", to: "/profile/threads", icon: MessageCircle, badgeKey: "chat" as const },
|
||||||
{ label: "دنبالشدهها", to: "/profile/following", icon: CircleFadingPlus },
|
{ label: "دنبالشدهها", to: "/profile/following", icon: CircleFadingPlus },
|
||||||
{ label: "ذخیرهشدهها", to: "/profile/bookmarks", icon: BookmarkMinus },
|
{ label: "ذخیرهشدهها", to: "/profile/bookmarks", icon: BookmarkMinus },
|
||||||
{ label: "ستهای من", to: "/profile/sets", icon: Layers },
|
{ label: "ستهای من", to: "/profile/sets", icon: Layers },
|
||||||
@ -34,6 +35,7 @@ export default function ProfileLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user } = useRootData();
|
const { user } = useRootData();
|
||||||
const { data: profile } = useServiceGetProfile();
|
const { data: profile } = useServiceGetProfile();
|
||||||
|
const { data: badges } = useBadgeCounts();
|
||||||
|
|
||||||
const isActive = (to: string) =>
|
const isActive = (to: string) =>
|
||||||
location.pathname === to || location.pathname.startsWith(to + "/");
|
location.pathname === to || location.pathname.startsWith(to + "/");
|
||||||
@ -56,21 +58,29 @@ export default function ProfileLayout() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<nav className="p-2.5 flex flex-col gap-0.5">
|
<nav className="p-2.5 flex flex-col gap-0.5">
|
||||||
{NAV.map((n) => (
|
{NAV.map((n) => {
|
||||||
<Link
|
const count = n.badgeKey ? badges?.[n.badgeKey] || 0 : 0;
|
||||||
key={n.to}
|
return (
|
||||||
to={n.to}
|
<Link
|
||||||
prefetch="intent"
|
key={n.to}
|
||||||
className={`flex items-center gap-3 px-3.5 py-3 rounded-xl text-[14.5px] font-semibold transition-colors ${
|
to={n.to}
|
||||||
isActive(n.to)
|
prefetch="intent"
|
||||||
? "bg-BLACK text-white"
|
className={`flex items-center gap-3 px-3.5 py-3 rounded-xl text-[14.5px] font-semibold transition-colors ${
|
||||||
: "text-BLACK2 hover:bg-WHITE2"
|
isActive(n.to)
|
||||||
}`}
|
? "bg-BLACK text-white"
|
||||||
>
|
: "text-BLACK2 hover:bg-WHITE2"
|
||||||
<n.icon size={20} />
|
}`}
|
||||||
{n.label}
|
>
|
||||||
</Link>
|
<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 ? (
|
{user?.access_token ? (
|
||||||
<Link
|
<Link
|
||||||
to="/logout"
|
to="/logout"
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { useStoreOwnership } from "../hooks/useStoreOwnership";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SunMoonToggle } from "~/components/SunMoonToggle";
|
import { SunMoonToggle } from "~/components/SunMoonToggle";
|
||||||
import { useRootData } from "~/hooks/use-root-data";
|
import { useRootData } from "~/hooks/use-root-data";
|
||||||
|
import { PushSettingToggle } from "~/components/notifications/PushSettingToggle";
|
||||||
|
|
||||||
// Import the InstagramSyncDrawer component
|
// Import the InstagramSyncDrawer component
|
||||||
import { InstagramSyncDrawer } from "./store.$storeId._index";
|
import { InstagramSyncDrawer } from "./store.$storeId._index";
|
||||||
@ -149,7 +150,7 @@ export default function StoreSetting() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Status + Theme cards */}
|
{/* 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 */}
|
{/* 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="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">
|
<div className="flex w-full items-center justify-between">
|
||||||
@ -190,6 +191,9 @@ export default function StoreSetting() {
|
|||||||
<SunMoonToggle theme={initialTheme} />
|
<SunMoonToggle theme={initialTheme} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Menu Items */}
|
{/* Menu Items */}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useSellerData } from "~/requestHandler/use-seller-hooks";
|
import { useSellerData } from "~/requestHandler/use-seller-hooks";
|
||||||
import SellerLogo from "~/components/SellerLogo";
|
import SellerLogo from "~/components/SellerLogo";
|
||||||
|
import { useBadgeCounts } from "~/hooks/useBadgeCounts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop layout for the seller dashboard. Renders a sticky admin sidebar
|
* Desktop layout for the seller dashboard. Renders a sticky admin sidebar
|
||||||
@ -21,15 +22,26 @@ export default function StoreLayout() {
|
|||||||
const { storeId } = useParams();
|
const { storeId } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { data } = useSellerData(storeId);
|
const { data } = useSellerData(storeId);
|
||||||
|
const { data: badges } = useBadgeCounts();
|
||||||
const base = `/store/${storeId}`;
|
const base = `/store/${storeId}`;
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ label: "فروشگاه", to: base, icon: LayoutGrid, exact: true },
|
{ label: "فروشگاه", to: base, icon: LayoutGrid, exact: true },
|
||||||
{ label: "محصولات", to: `${base}/products`, icon: Package },
|
{ 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}/financial-dashboard`, icon: Wallet },
|
||||||
{ label: "روش ارسال", to: `${base}/shipping-method`, icon: Truck },
|
{ 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 },
|
{ label: "تنظیمات", to: `${base}/setting`, icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -65,6 +77,11 @@ export default function StoreLayout() {
|
|||||||
>
|
>
|
||||||
<n.icon size={20} />
|
<n.icon size={20} />
|
||||||
{n.label}
|
{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>
|
||||||
))}
|
))}
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
52
public/sw.js
52
public/sw.js
@ -142,3 +142,55 @@ self.addEventListener("message", (event) => {
|
|||||||
self.skipWaiting();
|
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