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:
parent
7a385ba18e
commit
0b18438a97
@ -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<string | null>(null);
|
||||
|
||||
// Initialize state from sessionStorage on mount - only once
|
||||
|
||||
@ -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" });
|
||||
|
||||
99
app/components/store/StorePushPromptCard.tsx
Normal file
99
app/components/store/StorePushPromptCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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<string, 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 = {
|
||||
all: ["notif-badges"] as const,
|
||||
|
||||
@ -77,7 +77,7 @@ export default function Profile() {
|
||||
label: "سفارشها",
|
||||
link: "/profile/orders",
|
||||
icon: <ShoppingBag size={22} />,
|
||||
badge: (badgeCounts?.order_updates || null) as number | null,
|
||||
badge: (badgeCounts?.orderUpdates || null) as number | null,
|
||||
},
|
||||
{
|
||||
label: "نشانها",
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user