240 lines
10 KiB
TypeScript
240 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import Header from "../components/Header";
|
|
import Footer from "../components/Footer";
|
|
import { NetworkBanner } from "../components/NetworkBanner";
|
|
import NavigationProgressBar from "../components/NavigationProgressBar";
|
|
import { Toaster, toast } from "sonner";
|
|
import { usePathname, useRouter } from 'next/navigation';
|
|
import dynamic from 'next/dynamic';
|
|
|
|
const CartDrawer = dynamic(() => import("../components/CartDrawer"), { ssr: false });
|
|
const LoginModal = dynamic(() => import("../components/LoginModal"), { ssr: false });
|
|
const AuthModal = dynamic(() => import("../components/AuthModal"), { ssr: false });
|
|
const B2BPortal = dynamic(() => import("../components/B2BPortal"), { ssr: false });
|
|
const MobileBottomNav = dynamic(() => import("../components/MobileBottomNav"), { ssr: false });
|
|
const PrescriptionUploadModal = dynamic(() => import("../components/PrescriptionUploadModal"), { ssr: false });
|
|
|
|
import { useUserStore } from "../lib/store/userStore";
|
|
import { useSettingsStore } from "../lib/store/settingsStore";
|
|
import { useUIStore } from "../lib/store/uiStore";
|
|
|
|
import MaintenancePage from "../components/MaintenancePage";
|
|
import { NavigationTarget } from "../lib/types";
|
|
|
|
// Module-level flag: survives across effect re-runs because it lives outside the component.
|
|
// When the user presses Back/Forward, popstate fires BEFORE pathname changes.
|
|
// The flag is read (and reset) in the pathname effect which runs AFTER.
|
|
let _wasPopNavigation = false;
|
|
|
|
|
|
export default function ClientLayout({ children }: { children: React.ReactNode }) {
|
|
const isCartOpen = useUIStore((state) => state.isCartOpen);
|
|
const isLoginModalOpen = useUIStore((state) => state.isLoginModalOpen);
|
|
const isB2BPortalOpen = useUIStore((state) => state.isB2BPortalOpen);
|
|
const advisorData = useUIStore((state) => state.advisorData);
|
|
const setCartOpen = useUIStore((state) => state.setCartOpen);
|
|
const setLoginModalOpen = useUIStore((state) => state.setLoginModalOpen);
|
|
const setB2BPortalOpen = useUIStore((state) => state.setB2BPortalOpen);
|
|
const clearAdvisorData = useUIStore((state) => state.clearAdvisorData);
|
|
const [isPrescriptionModalOpen, setIsPrescriptionModalOpen] = useState(false);
|
|
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
|
|
const fetchProfile = useUserStore((state) => state.fetchProfile);
|
|
const role = useUserStore((state) => state.role);
|
|
const isAuthModalOpen = useUserStore((state) => state.isAuthModalOpen);
|
|
const setAuthModalOpen = useUserStore((state) => state.setAuthModalOpen);
|
|
|
|
const fetchSettings = useSettingsStore((state) => state.fetchSettings);
|
|
const getText = useSettingsStore((state) => state.getText);
|
|
const isB2BEnabled = useSettingsStore((state) => state.getBoolean('b2bRegistrationOpen', true) && state.getBoolean('b2b_enabled', true));
|
|
|
|
useEffect(() => {
|
|
fetchSettings();
|
|
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
|
|
if (token) {
|
|
fetchProfile().catch(e => console.error("Auth init failed:", e));
|
|
}
|
|
|
|
// Let the browser handle scroll restoration on Back/Forward navigation.
|
|
// We separately implement push-navigation scroll-to-top below.
|
|
if (typeof window !== 'undefined' && 'scrollRestoration' in window.history) {
|
|
window.history.scrollRestoration = 'manual';
|
|
}
|
|
|
|
// Permanent listener: set the flag whenever any popstate fires.
|
|
// This includes modal sentinel pops - those don't change pathname so they
|
|
// are harmless (the pathname effect won't run, flag stays set until the
|
|
// next real back navigation that DOES change pathname).
|
|
const handlePopState = () => { _wasPopNavigation = true; };
|
|
window.addEventListener('popstate', handlePopState);
|
|
return () => window.removeEventListener('popstate', handlePopState);
|
|
}, [fetchProfile, fetchSettings]);
|
|
|
|
|
|
// Scroll-to-top only on PUSH navigation, not on Back/Forward.
|
|
// _wasPopNavigation is set by the permanent popstate listener above
|
|
// BEFORE this effect runs for the new pathname.
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
if (_wasPopNavigation) {
|
|
// Back/Forward: restore the previously saved scroll position.
|
|
_wasPopNavigation = false;
|
|
const savedY = sessionStorage.getItem(`__scroll_${pathname}`);
|
|
if (savedY) {
|
|
const y = parseInt(savedY, 10);
|
|
// Double rAF: first waits for React commit, second waits for paint
|
|
requestAnimationFrame(() =>
|
|
requestAnimationFrame(() =>
|
|
window.scrollTo({ top: y, behavior: 'instant' })
|
|
)
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
// Push navigation (link click, router.push): scroll to top immediately.
|
|
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
|
}, [pathname]);
|
|
|
|
// Save current scroll position to sessionStorage (debounced + on cleanup)
|
|
// so it can be restored on Back navigation.
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
const key = `__scroll_${pathname}`;
|
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
|
const handleScroll = () => {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => {
|
|
sessionStorage.setItem(key, String(window.scrollY));
|
|
}, 100);
|
|
};
|
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
return () => {
|
|
clearTimeout(debounceTimer);
|
|
// Save immediately on exit (before pathname changes)
|
|
sessionStorage.setItem(key, String(window.scrollY));
|
|
window.removeEventListener('scroll', handleScroll);
|
|
};
|
|
}, [pathname]);
|
|
|
|
|
|
// Check Maintenance Mode (Admin / Partner bypass)
|
|
const isMaintenanceMode = getText('MAINTENANCE_MODE', 'false') === 'true' || getText('maintenance_mode', 'false') === 'true';
|
|
const isAdmin = role === 'User_Partner' || (typeof window !== 'undefined' && Boolean(localStorage.getItem('adminToken')));
|
|
|
|
if (isMaintenanceMode && !isAdmin) {
|
|
return <MaintenancePage />;
|
|
}
|
|
|
|
// Derived currentView from pathname for Header
|
|
let currentView = "home";
|
|
if (pathname?.includes("/shop")) currentView = "shop";
|
|
if (pathname?.includes("/wiki")) currentView = "wiki";
|
|
if (pathname?.includes("/profile")) currentView = "profile";
|
|
if (pathname?.includes("/checkout")) currentView = "checkout";
|
|
if (pathname?.includes("/dashboard")) currentView = "user-dashboard";
|
|
|
|
const handleNavigate = (v: NavigationTarget) => {
|
|
if (typeof v === 'object' && v !== null) {
|
|
if (v.view === 'profile') router.push('/profile');
|
|
if (v.view === 'order-tracking') router.push('/order/tracking');
|
|
if (v.view === 'home') router.push('/');
|
|
} else if (typeof v === 'string') {
|
|
if (v === 'home') router.push('/');
|
|
else if (v === 'user-dashboard') router.push('/dashboard');
|
|
else router.push(`/${v}`);
|
|
}
|
|
};
|
|
|
|
const navigateToShop = (category: string = "all", search: string = "") => {
|
|
const params = new URLSearchParams();
|
|
if (category && category !== "all") params.set("category", category);
|
|
if (search) params.set("search", search);
|
|
const newUrl = params.toString() ? `/shop?${params.toString()}` : "/shop";
|
|
router.push(newUrl);
|
|
};
|
|
|
|
const handleSearch = (q: string) => {
|
|
router.push(`/search?q=${encodeURIComponent(q)}`);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<NavigationProgressBar />
|
|
<NetworkBanner />
|
|
<Header
|
|
onNavigate={handleNavigate}
|
|
onShopNavigate={navigateToShop}
|
|
currentView={currentView}
|
|
onCartOpen={() => setCartOpen(true)}
|
|
onSearch={handleSearch}
|
|
onB2BOpen={() => setB2BPortalOpen(true)}
|
|
/>
|
|
<main className={`min-h-screen ${pathname === '/catalog' ? 'bg-slate-950' : ''}`}>
|
|
{children}
|
|
</main>
|
|
<Footer onNavigate={handleNavigate} onShopNavigate={navigateToShop} onB2BOpen={() => setB2BPortalOpen(true)} />
|
|
|
|
{isB2BPortalOpen && isB2BEnabled && (
|
|
<B2BPortal onClose={() => setB2BPortalOpen(false)} />
|
|
)}
|
|
<CartDrawer
|
|
isOpen={isCartOpen}
|
|
onClose={() => setCartOpen(false)}
|
|
onCheckout={() => { setCartOpen(false); router.push('/checkout'); }}
|
|
onShopNavigate={navigateToShop}
|
|
/>
|
|
<LoginModal
|
|
isOpen={isLoginModalOpen}
|
|
onClose={() => {
|
|
setLoginModalOpen(false);
|
|
clearAdvisorData();
|
|
}}
|
|
onLogin={async () => {
|
|
setLoginModalOpen(false);
|
|
if (advisorData) {
|
|
try {
|
|
const { usePetStore } = await import("../lib/store/usePetStore");
|
|
const petToSave = {
|
|
name: String(advisorData.name || "پت من"),
|
|
type: (advisorData.type as "سگ" | "گربه") || "سگ",
|
|
breed: String(advisorData.breed || ""),
|
|
age: Number(advisorData.age || 1),
|
|
weight: Number(advisorData.weight || 10),
|
|
activityLevel: (advisorData.activityLevel as "کم" | "متوسط" | "زیاد") || "متوسط",
|
|
medicalConditions: Array.isArray(advisorData.medicalConditions) ? advisorData.medicalConditions : [],
|
|
reminders: [],
|
|
logs: [],
|
|
consumptions: []
|
|
};
|
|
await usePetStore.getState().addPet(petToSave);
|
|
toast.success(`شناسنامه سلامت ${petToSave.name} با موفقیت ثبت شد`);
|
|
clearAdvisorData();
|
|
router.push('/profile');
|
|
} catch (e) {
|
|
console.error("Auto pet registration after login failed:", e);
|
|
}
|
|
}
|
|
}}
|
|
petName={advisorData?.name as string | undefined}
|
|
isAdvisorContext={!!advisorData}
|
|
/>
|
|
<AuthModal
|
|
isOpen={isAuthModalOpen}
|
|
onClose={() => setAuthModalOpen(false)}
|
|
/>
|
|
<PrescriptionUploadModal
|
|
isOpen={isPrescriptionModalOpen}
|
|
onClose={() => setIsPrescriptionModalOpen(false)}
|
|
/>
|
|
<MobileBottomNav
|
|
onPrescriptionOpen={() => setIsPrescriptionModalOpen(true)}
|
|
/>
|
|
<Toaster position="top-center" expand={true} richColors closeButton />
|
|
</>
|
|
);
|
|
}
|