fix(notif+auth): camelCase badge fields, seller push prompt, only 401 signs out

- 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 <noreply@anthropic.com>
This commit is contained in:
fazli 2026-07-18 13:18:08 +00:00
parent 7a385ba18e
commit 0b18438a97
6 changed files with 122 additions and 19 deletions

View File

@ -105,7 +105,7 @@ const MobileBottomNavigation = ({
// Buyer-facing account activity (unread chats + order status changes) is // Buyer-facing account activity (unread chats + order status changes) is
// surfaced on the profile icon, since both live under /profile. // surfaced on the profile icon, since both live under /profile.
const accountUnread = const accountUnread =
(badgeCounts?.chat || 0) + (badgeCounts?.order_updates || 0); (badgeCounts?.chat || 0) + (badgeCounts?.orderUpdates || 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

View File

@ -12,7 +12,7 @@ const HomePageTopBar = memo(() => {
const cartCount = useCartCount(); const cartCount = useCartCount();
const { data: badgeCounts } = useBadgeCounts(); const { data: badgeCounts } = useBadgeCounts();
const accountUnread = const accountUnread =
(badgeCounts?.chat || 0) + (badgeCounts?.order_updates || 0); (badgeCounts?.chat || 0) + (badgeCounts?.orderUpdates || 0);
const scrollToTop = useCallback(() => { const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });

View File

@ -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 (
<div className="mb-4 rounded-2xl border border-RED/30 bg-RED/5 p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-RED/10 grid place-items-center shrink-0">
<BellOff size={20} className="text-RED" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-[14.5px] text-BLACK">
اعلانها در مرورگر مسدود شدهاند
</p>
<p className="text-[13px] text-BLACK2 leading-6 mt-1">
برای دریافت اعلان سفارشهای جدید، از تنظیمات مرورگر یا PWA اعلانها را برای این سایت فعال کنید و سپس صفحه را رفرش کنید.
</p>
</div>
</div>
</div>
);
}
// Default (never asked) OR granted-but-not-subscribed — both resolvable by
// clicking Enable, which triggers the PushManager subscribe flow.
return (
<div className="mb-4 rounded-2xl border border-VITROWN_BLUE/20 bg-VITROWN_BLUE/5 p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-VITROWN_BLUE/10 grid place-items-center shrink-0">
<Bell size={20} className="text-VITROWN_BLUE" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-[14.5px] text-BLACK">
اعلان سفارشهای جدید را فعال کنید
</p>
<p className="text-[13px] text-BLACK2 leading-6 mt-1">
تا لحظهای که یک مشتری سفارش میثبت، روی مرورگر یا موبایل خود پیامی میگیرید و هیچ سفارشی را از دست نمیدهید.
</p>
<div className="flex items-center gap-2 mt-3">
<button
onClick={enable}
disabled={busy}
className="inline-flex items-center gap-1.5 rounded-xl bg-VITROWN_BLUE text-white text-[13px] font-bold px-3.5 py-2 disabled:opacity-60"
>
{busy ? "..." : "فعال‌سازی"}
<ChevronLeft size={16} />
</button>
<button
onClick={snoozeUntilTomorrow}
className="text-[13px] font-semibold text-GRAY px-2 py-2"
>
بعداً یادآوری کن
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -2,13 +2,22 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthToken } from "./use-root-data"; import { useAuthToken } from "./use-root-data";
import { getApiBaseUrl } from "~/utils/api-config"; 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 { export interface BadgeCounts {
chat: number; chat: number;
order_updates: number; sellerChat: Record<string, number>;
seller_orders: number; 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 = { export const badgeKeys = {
all: ["notif-badges"] as const, all: ["notif-badges"] as const,

View File

@ -77,7 +77,7 @@ export default function Profile() {
label: "سفارش‌ها", label: "سفارش‌ها",
link: "/profile/orders", link: "/profile/orders",
icon: <ShoppingBag size={22} />, icon: <ShoppingBag size={22} />,
badge: (badgeCounts?.order_updates || null) as number | null, badge: (badgeCounts?.orderUpdates || null) as number | null,
}, },
{ {
label: "نشان‌ها", label: "نشان‌ها",

View File

@ -1,25 +1,21 @@
/** /**
* API Error Handler Middleware * 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"; 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 => ({ export const createAuthErrorMiddleware = (): Middleware => ({
post: async (context) => { post: async (context) => {
const response = context.response; const response = context.response;
// Check if response is 403 Unauthorized if (response.status === 401) {
if (response.status === 403 || response.status === 401) { console.warn("[API Error Handler] 401 Unauthorized — logging out user");
console.warn(
"[API Error Handler] 403 Unauthorized detected - logging out user"
);
// Only redirect if we're in browser context
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.location.href = "/logout"; window.location.href = "/logout";
} }
@ -29,7 +25,6 @@ export const createAuthErrorMiddleware = (): Middleware => ({
}, },
onError: async (context) => { onError: async (context) => {
// Handle network errors or other fetch errors
console.error("[API Error Handler] Request failed:", context.error); console.error("[API Error Handler] Request failed:", context.error);
return context.response; return context.response;
}, },