Vitron-Front/app/components/MyLayout.tsx
Arda Samadi fef53fd003 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>
2026-06-17 14:02:56 +03:30

414 lines
14 KiB
TypeScript

import React, { memo, useMemo, useEffect, useState } from "react";
import buyOutline from "app/assets/icons/navbar/buy-outline.svg";
import homeOutline from "app/assets/icons/navbar/home-outline.svg";
import profileOutline from "app/assets/icons/navbar/profile-outline.svg";
import searchOutline from "app/assets/icons/navbar/search-outline.svg";
import buyFilled from "app/assets/icons/navbar/buy-filled.svg";
import homeFilled from "app/assets/icons/navbar/home-filled.svg";
import profileFilled from "app/assets/icons/navbar/profile-filled.svg";
import searchFilled from "app/assets/icons/navbar/search-filled.svg";
import { Link, useLocation, useParams } from "@remix-run/react";
import DesktopHeader from "~/components/desktop/DesktopHeader";
import DesktopFooter from "~/components/desktop/DesktopFooter";
import { detectPage } from "~/utils/helpers";
import { useRootData } from "~/hooks/use-root-data";
import { useCartCount } from "~/requestHandler/use-cart-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 settingIconOutline from "~/assets/icons/navbar/setting-outline.svg";
import documentFilled from "~/assets/icons/navbar/document-filled.svg";
import documentOutline from "~/assets/icons/navbar/document-outline.svg";
import productsFilled from "~/assets/icons/navbar/products-filled.svg";
import productsOutline from "~/assets/icons/navbar/products-outline.svg";
/**
* Primary application layout component
* Renders either mobile or desktop header based on screen size
*/
const MyLayout = React.memo(({ children }: { children: React.ReactNode }) => {
const location = useLocation();
// Desktop chrome is rolled out per-page. Only routes matched here get the
// full-width desktop layout; everything else keeps the current (mobile)
// experience at all widths so nothing regresses while we build the rest.
const p = location.pathname;
const isDesktopReady =
p === "/" ||
p === "/explore" ||
p.startsWith("/search/") ||
p.startsWith("/product/") ||
p.startsWith("/seller/") ||
p === "/collections" ||
p.startsWith("/collection/") ||
p === "/cart" ||
p.startsWith("/cart/") ||
p === "/sellers" ||
p.startsWith("/profile");
// Auth pages get a full-width desktop canvas (so they can center their own
// card) but no shopping header/footer.
const isAuth = p === "/login" || p === "/logout";
// Seller dashboard: full-width canvas + its own sidebar (store.$storeId.tsx),
// no shopper header/footer.
const isStore = p.startsWith("/store/");
const expandOnDesktop = isDesktopReady || isAuth || isStore;
const isThread = !!location.pathname.split("threads")[1];
return (
<div className="min-h-screen bg-gray-100 lg:bg-gray-200 lg:flex lg:flex-col">
{isDesktopReady && <DesktopHeader />}
<div
className={`max-w-md mx-auto bg-WHITE min-h-screen shadow-lg lg:shadow-xl ${
expandOnDesktop
? "lg:max-w-none lg:mx-0 lg:min-h-0 lg:flex-1 lg:shadow-none"
: ""
}`}
>
<div
className={`pb-[calc(50px+env(safe-area-inset-bottom))] ${
expandOnDesktop ? "lg:pb-0" : ""
}`}
>
{children}
{isThread ? (
<></>
) : (
<MobileBottomNavigation hideOnDesktop={expandOnDesktop} />
)}
<PushPermissionBanner />
</div>
</div>
{isDesktopReady && <DesktopFooter />}
</div>
);
});
MyLayout.displayName = "Layout";
const MobileBottomNavigation = ({
hideOnDesktop = false,
}: {
hideOnDesktop?: boolean;
}) => {
const { theme } = useRootData();
const location = useLocation();
const currentPage = useMemo(
() => detectPage(location?.pathname),
[location?.pathname]
);
const cartCount = useCartCount();
const { user } = useRootData();
const [isStoreMode, setIsStoreMode] = useState(false);
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);
// Initialize state from sessionStorage on mount - only once
useEffect(() => {
if (typeof window === "undefined") return;
const savedMode = sessionStorage.getItem("navigationMode");
const savedStoreId = sessionStorage.getItem("currentStoreId");
if (savedMode === "store" && savedStoreId) {
setIsStoreMode(true);
setStoreId(savedStoreId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const params = useParams();
// Function to determine page type
const getPageType = useMemo(() => {
const path = location.pathname;
const mode = params.mode;
// Store management pages
const isStoreManagementPage =
path.match(/^\/store\/[^/]+$/) || // Main store page: /store/{id}
path.match(/^\/store\/[^/]+\/setting$/) || // Store settings: /store/{id}/setting
path.match(/^\/store\/[^/]+\/orders/) || // Store orders: /store/{id}/orders
path.match(/^\/store\/[^/]+\/shipping-method$/) || // Shipping method: /store/{id}/shipping-method
path.match(/^\/store\/[^/]+\/financial-dashboard$/) || // Financial dashboard: /store/{id}/financial-dashboard
path.match(/^\/store\/[^/]+\/financial-dashboard\/cash-funds$/) || // Financial dashboard: /store/{id}/financial-dashboard
path.match(/^\/store\/[^/]+\/financial-dashboard\/transactions$/) || // Financial dashboard: /store/{id}/financial-dashboard
path.match(/^\/store\/[^/]+\/financial-dashboard\/reports$/) || // Financial dashboard: /store/{id}/financial-dashboard
path.match(/^\/store\/[^/]+\/products$/) || // Products list: /store/{id}/products
path.match(/^\/store\/[^/]+\/product\/[^/]+$/) || // Product detail: /store/{id}/products/{id}
path.match(/^\/store\/[^/]+\/edit$/) || // Edit store: /store/{id}/edit
path.match(/^\/store\/add\/manual\/[^/]+$/) || // Add product: /store/add/manual/{id}
path.match(/^\/store\/create$/) || // Create store: /store/create
path.match(/^\/store\/authenticate\/instagram\/[^/]+$/) || // Instagram auth: /store/authenticate/instagram/{id}
path.match(/^\/store\/[^/]+\/threads$/) || // Threads: /store/{id}/threads
path.match(/^\/store\/[^/]+\/threads\/[^/]+$/); // Threads: /store/{id}/threads/{threadId}
// Shared pages (should preserve current mode)
const isSharedPage =
path.match(/^\/explore$/) || // Explore page
path.match(/^\/search\//) || // Search pages
path.match(/^\/product\//) || // Product detail pages
path.match(/^\/seller\/[^/]+$/); // Seller view pages (not management)
// Regular user pages
const isRegularPage =
path.match(/^\/profile/) || // Profile pages
path.match(/^\/cart/) || // Cart pages
path.match(/^\/login$/) || // Login page
path.match(/^\/logout$/) || // Logout page
path === "/"; // Home page
if (isStoreManagementPage || mode === "store") return "store";
if (isSharedPage) return "shared";
if (isRegularPage) return "regular";
return "regular"; // Default
}, [location.pathname, params.mode]);
// Update store mode based on current page
useEffect(() => {
const pageType = getPageType;
const extractStoreId = (path: string): string | null => {
let match;
// Order matters here: more specific paths first.
// Case: /store/add/manual/{id}
match = path.match(/^\/store\/add\/manual\/([^/]+)/);
if (match) return match[1];
// Case: /store/authenticate/instagram/{id}
match = path.match(/^\/store\/authenticate\/instagram\/([^/]+)/);
if (match) return match[1];
// Case: /store/{id}/* (e.g., /store/my-store/settings)
match = path.match(/^\/store\/([^/]+)\//);
if (match) return match[1];
// Case: /store/{id} (e.g., /store/my-store)
match = path.match(/^\/store\/([^/]+)$/);
if (match && match[1] !== "create") {
return match[1];
}
return null;
};
if (pageType === "store") {
// We're in store management - set store mode
const currentStoreId = extractStoreId(location.pathname);
if (currentStoreId) {
setIsStoreMode((prev) => {
if (!prev && typeof window !== "undefined") {
sessionStorage.setItem("navigationMode", "store");
}
return true;
});
setStoreId((prev) => {
if (prev !== currentStoreId && typeof window !== "undefined") {
sessionStorage.setItem("currentStoreId", currentStoreId);
}
return currentStoreId;
});
} else {
// It's a store page without an ID, like /store/create.
setIsStoreMode((prev) => {
if (prev && typeof window !== "undefined") {
sessionStorage.setItem("navigationMode", "regular");
sessionStorage.removeItem("currentStoreId");
}
return false;
});
setStoreId(null);
}
} else if (pageType === "regular") {
// We're in regular user pages - set regular mode
setIsStoreMode((prev) => {
if (prev && typeof window !== "undefined") {
sessionStorage.setItem("navigationMode", "regular");
sessionStorage.removeItem("currentStoreId");
}
return false;
});
setStoreId(null);
// Clear store mode if user logs out
if (location.pathname === "/logout" && typeof window !== "undefined") {
sessionStorage.removeItem("navigationMode");
sessionStorage.removeItem("currentStoreId");
}
} else if (pageType === "shared") {
// We're in shared pages - preserve current mode (don't read from storage on every navigation)
// State is already set from initial mount
}
}, [location.pathname, getPageType]);
const navItems = useMemo(() => {
if (isStoreMode) {
// Store mode navigation
return [
{
link: `/store/${storeId}/setting`,
icon:
currentPage === "setting"
? theme === "dark"
? settingIconOutline
: settingIconFilled
: theme === "dark"
? settingIconFilled
: settingIconOutline,
},
{
link: `/store/${storeId}/orders`,
icon:
currentPage === "orders"
? theme === "dark"
? documentOutline
: documentFilled
: theme === "dark"
? documentFilled
: documentOutline,
badge:
sellerOrderCount > 0 && user?.access_token
? sellerOrderCount
: null,
},
{
link: `/store/${storeId}/products`,
icon:
currentPage === "products"
? theme === "dark"
? productsOutline
: productsFilled
: theme === "dark"
? productsFilled
: productsOutline,
},
{
link: `/store/${storeId}`,
icon:
currentPage === "store"
? theme === "dark"
? homeOutline
: homeFilled
: theme === "dark"
? homeFilled
: homeOutline,
},
];
} else {
// Normal mode navigation
return [
{
link: "/profile",
icon:
currentPage === "profile"
? theme === "dark"
? profileOutline
: profileFilled
: theme === "dark"
? profileFilled
: profileOutline,
badge:
accountUnread > 0 && user?.access_token ? accountUnread : null,
},
{
link: "/cart",
icon:
currentPage === "cart"
? theme === "dark"
? buyOutline
: buyFilled
: theme === "dark"
? buyFilled
: buyOutline,
badge: cartCount > 0 && user?.access_token ? cartCount : null,
},
{
link: "/explore",
icon:
currentPage === "explore" || currentPage === "search"
? theme === "dark"
? searchOutline
: searchFilled
: theme === "dark"
? searchFilled
: searchOutline,
},
{
link: "/",
icon:
currentPage === "home"
? theme === "dark"
? homeOutline
: homeFilled
: theme === "dark"
? homeFilled
: homeOutline,
},
];
}
}, [
currentPage,
cartCount,
sellerOrderCount,
accountUnread,
user?.access_token,
isStoreMode,
storeId,
theme,
]);
return (
<>
<div
className={`h-[50px] z-20 bg-WHITE flex gap-[48px] px-[20px] fixed bottom-0 border-t w-full max-w-md mx-auto items-center justify-center pb-[env(safe-area-inset-bottom)] lg:left-1/2 lg:transform lg:-translate-x-1/2 ${
hideOnDesktop ? "lg:hidden" : ""
}`}
>
{navItems.map((item) => (
<Link
key={item.link}
to={item.link}
prefetch="intent"
className="relative"
>
{typeof item.icon === "string" ? (
<img
src={item.icon}
className="h-6 w-6"
alt={item.link}
loading="eager"
/>
) : (
item.icon
)}
{item.badge ? (
<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 > 99 ? "99+" : item.badge}
</span>
) : null}
</Link>
))}
</div>
</>
);
};
const LanguageToggle = memo(
({
onToggle,
currentLanguage,
}: {
onToggle: () => Promise<void>;
currentLanguage: string;
}) => (
<button onClick={onToggle}>{currentLanguage === "fa" ? "En" : "فا"}</button>
)
);
LanguageToggle.displayName = "LanguageToggle";
export default MyLayout;