Content was drawing under the iOS status bar and home indicator because the app uses viewport-fit=cover + black-translucent but the fixed/sticky chrome didn't reserve the safe areas. - bottom nav (MyLayout): move the 50px row into an inner box and let the bar extend by env(safe-area-inset-bottom) so icons sit above the home indicator (previously the fixed height clipped the inset padding). - top headers gain env(safe-area-inset-top): HomePageTopBar, ExploreTopBar, SearchTopBar, CartHeader, ChatHeader, and wrapper-padded HeaderWithSupport / ProfilePagesHeader (so their absolutely-positioned buttons shift too). - chat input pads bottom by the inset; chat scroll area height accounts for the taller header/input. - _index.tsx no longer overrides the viewport meta (it was dropping viewport-fit=cover on the home page); the global one in root.tsx wins. env(safe-area-inset-*) is 0 on non-notch devices, so desktop/Android render identically — only iOS gains the padding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
416 lines
15 KiB
TypeScript
416 lines
15 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={`z-20 bg-WHITE fixed bottom-0 border-t w-full max-w-md mx-auto pb-[env(safe-area-inset-bottom)] lg:left-1/2 lg:transform lg:-translate-x-1/2 ${
|
|
hideOnDesktop ? "lg:hidden" : ""
|
|
}`}
|
|
>
|
|
<div className="h-[50px] flex gap-[48px] px-[20px] items-center justify-center">
|
|
{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>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const LanguageToggle = memo(
|
|
({
|
|
onToggle,
|
|
currentLanguage,
|
|
}: {
|
|
onToggle: () => Promise<void>;
|
|
currentLanguage: string;
|
|
}) => (
|
|
<button onClick={onToggle}>{currentLanguage === "fa" ? "En" : "فا"}</button>
|
|
)
|
|
);
|
|
LanguageToggle.displayName = "LanguageToggle";
|
|
|
|
export default MyLayout;
|