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
|
// 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
|
||||||
|
|||||||
@ -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" });
|
||||||
|
|||||||
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 { 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,
|
||||||
|
|||||||
@ -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: "نشانها",
|
||||||
|
|||||||
@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user