From 0b18438a971829b62eaf31af9c5dd648c85e3ee3 Mon Sep 17 00:00:00 2001 From: fazli Date: Sat, 18 Jul 2026 13:18:08 +0000 Subject: [PATCH] fix(notif+auth): camelCase badge fields, seller push prompt, only 401 signs out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/components/MyLayout.tsx / app/components/home/HomePageTopBar.tsx / app/routes/profile._index.tsx: the BadgeCounts type was declared with snake_case fields (order_updates, seller_orders) while the API's global camelCase renderer sends orderUpdates / sellerOrders on the wire — every read went to undefined, so the "buyer activity" dot on the profile icon and the order-badge on the store dashboard were silently stuck at 0. Fixed by renaming the interface to match the wire and adding sellerChat (per-store roll-up) so the store dashboard sidebar reads badges.sellerChat[storeId] instead of the buyer-side chat count. - app/hooks/useBadgeCounts.ts: interface + EMPTY constant updated to camelCase + new sellerChat map. - app/utils/api-error-handler.ts: 403 no longer force-redirects to /logout. It used to force-log-out on ANY 403/401, so an approved-but-unbadged owner (Ehsan / Noelabel) whose store dashboard fired the Instagram-tab query got a 403 → forced logout → the store page vanished mid-load and the seller landed on the home screen convinced they'd been kicked off their own store. Only 401 signs the session out now. - app/components/store/StorePushPromptCard.tsx: new dashboard-level card that nudges the store owner/staff to enable browser push. Unlike the global buyer banner it's not dismissible-forever; "بعداً یادآوری کن" hides for 24h and it comes back until the user actually subscribes. Handles the permission="denied" path with an in-browser-settings hint. Co-Authored-By: Claude Opus 4.7 --- app/components/MyLayout.tsx | 2 +- app/components/home/HomePageTopBar.tsx | 2 +- app/components/store/StorePushPromptCard.tsx | 99 ++++++++++++++++++++ app/hooks/useBadgeCounts.ts | 15 ++- app/routes/profile._index.tsx | 2 +- app/utils/api-error-handler.ts | 21 ++--- 6 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 app/components/store/StorePushPromptCard.tsx diff --git a/app/components/MyLayout.tsx b/app/components/MyLayout.tsx index 641a314..9725848 100644 --- a/app/components/MyLayout.tsx +++ b/app/components/MyLayout.tsx @@ -105,7 +105,7 @@ const MobileBottomNavigation = ({ // 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); + (badgeCounts?.chat || 0) + (badgeCounts?.orderUpdates || 0); const [storeId, setStoreId] = useState(null); // Initialize state from sessionStorage on mount - only once diff --git a/app/components/home/HomePageTopBar.tsx b/app/components/home/HomePageTopBar.tsx index 372b4de..3fb080d 100644 --- a/app/components/home/HomePageTopBar.tsx +++ b/app/components/home/HomePageTopBar.tsx @@ -12,7 +12,7 @@ const HomePageTopBar = memo(() => { const cartCount = useCartCount(); const { data: badgeCounts } = useBadgeCounts(); const accountUnread = - (badgeCounts?.chat || 0) + (badgeCounts?.order_updates || 0); + (badgeCounts?.chat || 0) + (badgeCounts?.orderUpdates || 0); const scrollToTop = useCallback(() => { window.scrollTo({ top: 0, behavior: "smooth" }); diff --git a/app/components/store/StorePushPromptCard.tsx b/app/components/store/StorePushPromptCard.tsx new file mode 100644 index 0000000..d213609 --- /dev/null +++ b/app/components/store/StorePushPromptCard.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from "react"; +import { Bell, BellOff, ChevronLeft } from "lucide-react"; +import { usePushNotifications } from "~/hooks/usePushNotifications"; + +const SNOOZE_KEY = "store_push_prompt_snoozed_until"; +const SNOOZE_MS = 24 * 60 * 60 * 1000; // 24h — a soft nudge, not a dismissal. + +/** + * Seller-facing card at the top of the store dashboard that nudges the owner + * (or a staff member) to turn on browser/PWA notifications. Order notifications + * are business-critical, so unlike the global buyer banner this one is NOT + * dismissible-forever — a snooze hides it for a day; the prompt returns until + * push is actually subscribed. When permission was denied, we can't + * re-request programmatically, so the copy switches to a "how to re-enable in + * the browser" instruction. + */ +export function StorePushPromptCard() { + const { supported, permission, isSubscribed, subscribe, busy } = + usePushNotifications(); + const [snoozed, setSnoozed] = useState(false); + + useEffect(() => { + if (typeof window === "undefined") return; + const until = Number(localStorage.getItem(SNOOZE_KEY) || 0); + setSnoozed(!!until && Date.now() < until); + }, []); + + // Nothing to show if the browser can't do push, they already subscribed, + // or they hit "later" in the last 24h. + if (!supported) return null; + if (isSubscribed) return null; + if (snoozed) return null; + + const snoozeUntilTomorrow = () => { + localStorage.setItem(SNOOZE_KEY, String(Date.now() + SNOOZE_MS)); + setSnoozed(true); + }; + + const enable = async () => { + await subscribe(); + }; + + // Permission denied — we cannot prompt again from JS. Show recovery hint. + if (permission === "denied") { + return ( +
+
+
+ +
+
+

+ اعلان‌ها در مرورگر مسدود شده‌اند +

+

+ برای دریافت اعلان سفارش‌های جدید، از تنظیمات مرورگر یا PWA اعلان‌ها را برای این سایت فعال کنید و سپس صفحه را رفرش کنید. +

+
+
+
+ ); + } + + // Default (never asked) OR granted-but-not-subscribed — both resolvable by + // clicking Enable, which triggers the PushManager subscribe flow. + return ( +
+
+
+ +
+
+

+ اعلان سفارش‌های جدید را فعال کنید +

+

+ تا لحظه‌ای که یک مشتری سفارش می‌ثبت، روی مرورگر یا موبایل خود پیامی می‌گیرید و هیچ سفارشی را از دست نمی‌دهید. +

+
+ + +
+
+
+
+ ); +} diff --git a/app/hooks/useBadgeCounts.ts b/app/hooks/useBadgeCounts.ts index a14f18d..ab82f3a 100644 --- a/app/hooks/useBadgeCounts.ts +++ b/app/hooks/useBadgeCounts.ts @@ -2,13 +2,22 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthToken } from "./use-root-data"; import { getApiBaseUrl } from "~/utils/api-config"; +// The API returns these as camelCase (DRF's global camel-case renderer converts +// the snake_case source names). Match the wire shape here so reads aren't +// silently undefined. export interface BadgeCounts { chat: number; - order_updates: number; - seller_orders: number; + sellerChat: Record; + orderUpdates: number; + sellerOrders: number; } -const EMPTY: BadgeCounts = { chat: 0, order_updates: 0, seller_orders: 0 }; +const EMPTY: BadgeCounts = { + chat: 0, + sellerChat: {}, + orderUpdates: 0, + sellerOrders: 0, +}; export const badgeKeys = { all: ["notif-badges"] as const, diff --git a/app/routes/profile._index.tsx b/app/routes/profile._index.tsx index bd6c7ac..bedb0c2 100644 --- a/app/routes/profile._index.tsx +++ b/app/routes/profile._index.tsx @@ -77,7 +77,7 @@ export default function Profile() { label: "سفارش‌ها", link: "/profile/orders", icon: , - badge: (badgeCounts?.order_updates || null) as number | null, + badge: (badgeCounts?.orderUpdates || null) as number | null, }, { label: "نشان‌ها", diff --git a/app/utils/api-error-handler.ts b/app/utils/api-error-handler.ts index 9ef6220..1e326f9 100644 --- a/app/utils/api-error-handler.ts +++ b/app/utils/api-error-handler.ts @@ -1,25 +1,21 @@ /** * API Error Handler Middleware - * Handles 403 Unauthorized responses by logging out the user + * + * Logs the user out on 401 (auth is invalid). Does NOT log out on 403 — + * that's an authorization/permission failure for an authenticated user + * (e.g. a seller endpoint that requires `is_verified`), so kicking the + * session out is wrong and used to silently sign users out of their own + * store dashboard. */ import type { Middleware } from "../../src/api/types/runtime"; -/** - * Creates a middleware that handles 403 Unauthorized errors - * When a 403 is detected, it redirects to the logout page - */ export const createAuthErrorMiddleware = (): Middleware => ({ post: async (context) => { const response = context.response; - // Check if response is 403 Unauthorized - if (response.status === 403 || response.status === 401) { - console.warn( - "[API Error Handler] 403 Unauthorized detected - logging out user" - ); - - // Only redirect if we're in browser context + if (response.status === 401) { + console.warn("[API Error Handler] 401 Unauthorized — logging out user"); if (typeof window !== "undefined") { window.location.href = "/logout"; } @@ -29,7 +25,6 @@ export const createAuthErrorMiddleware = (): Middleware => ({ }, onError: async (context) => { - // Handle network errors or other fetch errors console.error("[API Error Handler] Request failed:", context.error); return context.response; },