Redesign mobile-style seller dashboard pages for desktop (lg:) while leaving the mobile view unchanged: - products: table-style header row (product/price/variant/actions) with a bordered card wrapper and an "add product" button; accordion rows align to the columns, inline price hidden on desktop. - orders: pill-style status tabs + search on one row, orders shown as a two-column card grid instead of stretched full-width rows. - financial-dashboard: three-column card grid with icon chips. - setting: status/theme as side-by-side cards, menu items as a three-column card grid (logout styled in red). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
||
import { ChevronDown, ChevronUp, Edit } from "lucide-react";
|
||
import { useNavigate, useParams } from "@remix-run/react";
|
||
import {
|
||
useGetSellerProductsList,
|
||
useUpdateVariantStock,
|
||
} from "../requestHandler/use-product-hooks";
|
||
import { cn } from "../lib/utils";
|
||
import { SellerProductListItem } from "../../src/api/types";
|
||
import placeholderImage from "~/assets/icons/product.png";
|
||
import placeholderImageDark from "~/assets/icons/product-dark.png";
|
||
import { useRootData } from "~/hooks/use-root-data";
|
||
import { ThrottledQuantityControl } from "../components/cart/ThrottledQuantityControl";
|
||
|
||
function ProductAccordionItem({
|
||
product,
|
||
isOpen,
|
||
onToggle,
|
||
isSticky,
|
||
storeId,
|
||
}: {
|
||
product: SellerProductListItem;
|
||
isOpen: boolean;
|
||
onToggle: () => void;
|
||
isSticky: boolean;
|
||
storeId: string;
|
||
}) {
|
||
const navigate = useNavigate();
|
||
const { theme } = useRootData();
|
||
const updateVariantStock = useUpdateVariantStock();
|
||
const [updatingVariantId, setUpdatingVariantId] = useState<string | null>(
|
||
null
|
||
);
|
||
// Use real variants from API
|
||
const variants = product.variants || [];
|
||
|
||
const handleEditProduct = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
navigate(`/store/${storeId}/product/${product.id}`);
|
||
};
|
||
|
||
return (
|
||
<div className="border-b border-gray-200">
|
||
<div
|
||
className={cn(
|
||
"transition-all duration-200",
|
||
isSticky && isOpen && "sticky top-0 z-10 bg-WHITE shadow-md"
|
||
)}
|
||
>
|
||
<button
|
||
onClick={onToggle}
|
||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-WHITE3 transition-colors"
|
||
>
|
||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||
{product.primaryImage && (
|
||
<img
|
||
src={
|
||
product.primaryImage ||
|
||
(theme === "dark" ? placeholderImageDark : placeholderImage)
|
||
}
|
||
alt={product.title}
|
||
onError={(e) => {
|
||
e.currentTarget.src =
|
||
theme === "dark" ? placeholderImageDark : placeholderImage;
|
||
}}
|
||
className="w-12 h-12 lg:w-14 lg:h-14 object-contain rounded"
|
||
/>
|
||
)}
|
||
<div className="text-right min-w-0">
|
||
<h3 className="font-medium text-sm lg:text-[15px] truncate">
|
||
{product.title}
|
||
</h3>
|
||
{/* Price shown inline on mobile only; desktop uses its own column */}
|
||
{product.basePrice && (
|
||
<p className="text-xs text-gray-600 mt-1 lg:hidden">
|
||
{parseInt(product.basePrice).toLocaleString("fa-IR")} تومان
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* Desktop price column */}
|
||
<span className="hidden lg:block w-32 text-center text-[14px] font-semibold">
|
||
{product.basePrice
|
||
? `${parseInt(product.basePrice).toLocaleString("fa-IR")} تومان`
|
||
: "—"}
|
||
</span>
|
||
{/* Desktop variants column */}
|
||
<span className="hidden lg:flex w-24 justify-center">
|
||
{variants && variants.length > 0 ? (
|
||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">
|
||
{variants.length} نوع
|
||
</span>
|
||
) : (
|
||
<span className="text-GRAY">—</span>
|
||
)}
|
||
</span>
|
||
<div className="flex items-center gap-2 lg:w-28 lg:justify-center">
|
||
{variants && variants.length > 0 && (
|
||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded lg:hidden">
|
||
{variants.length} نوع
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={handleEditProduct}
|
||
className="p-1.5 hover:bg-gray-200 rounded-lg transition-colors"
|
||
title="ویرایش محصول"
|
||
>
|
||
<Edit className="w-4 h-4 text-gray-600" />
|
||
</button>
|
||
{isOpen ? (
|
||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||
) : (
|
||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||
)}
|
||
</div>
|
||
</button>
|
||
</div>
|
||
|
||
{isOpen && variants && variants.length > 0 && (
|
||
<div className="px-4 py-3 bg-gray-50">
|
||
<div className="space-y-2">
|
||
{variants.map((variant, index) => {
|
||
const color = variant.attributes?.find(
|
||
(attribute) => attribute.name === "COLOR"
|
||
);
|
||
const size = variant.attributes?.find(
|
||
(attribute) => attribute.name === "SIZE"
|
||
);
|
||
return (
|
||
<div
|
||
key={variant.id || `variant-${index}`}
|
||
className="flex items-center justify-between p-3 bg-WHITE rounded-lg border border-gray-100"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
{color && (
|
||
<div
|
||
className="w-10 h-10 rounded-sm shadow-sm"
|
||
style={{
|
||
backgroundColor: color.info.detail,
|
||
}}
|
||
/>
|
||
)}
|
||
{size && (
|
||
<div
|
||
className="w-10 h-10 flex items-center justify-center rounded-sm shadow-sm"
|
||
style={{
|
||
backgroundColor: "black",
|
||
color: "white",
|
||
fontSize: "10px",
|
||
}}
|
||
>
|
||
{size.value}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex-1 flex justify-end">
|
||
<ThrottledQuantityControl
|
||
key={`${variant.id}-${variant.stock}`}
|
||
initialQuantity={variant.stock || 0}
|
||
maxQuantity={999}
|
||
onQuantityUpdate={(newQuantity) => {
|
||
const variantId = variant.id || "";
|
||
setUpdatingVariantId(variantId);
|
||
updateVariantStock.mutate(
|
||
{
|
||
variantId,
|
||
stock: newQuantity,
|
||
},
|
||
{
|
||
onSettled: () => {
|
||
setUpdatingVariantId(null);
|
||
},
|
||
}
|
||
);
|
||
}}
|
||
isPending={updatingVariantId === variant.id}
|
||
allowManualEdit={true}
|
||
throttleDelay={1000}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StoreProductsContent() {
|
||
const { storeId } = useParams();
|
||
const navigate = useNavigate();
|
||
const [openProductId, setOpenProductId] = useState<string | null>(null);
|
||
const [stickyProductId, setStickyProductId] = useState<string | null>(null);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
||
const {
|
||
data: products = [],
|
||
isLoading,
|
||
isError,
|
||
} = useGetSellerProductsList();
|
||
|
||
useEffect(() => {
|
||
const handleScroll = () => {
|
||
if (openProductId) {
|
||
setStickyProductId(openProductId);
|
||
}
|
||
};
|
||
|
||
window.addEventListener("scroll", handleScroll);
|
||
return () => window.removeEventListener("scroll", handleScroll);
|
||
}, [openProductId]);
|
||
|
||
const handleToggleAccordion = (productId: string) => {
|
||
if (openProductId === productId) {
|
||
setOpenProductId(null);
|
||
setStickyProductId(null);
|
||
} else {
|
||
setOpenProductId(productId);
|
||
setStickyProductId(productId);
|
||
}
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex justify-center items-center min-h-[400px]">
|
||
<div className="text-gray-500">در حال بارگذاری محصولات...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isError) {
|
||
return (
|
||
<div className="flex justify-center items-center min-h-[400px]">
|
||
<div className="text-red-500">خطا در بارگذاری محصولات</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (products.length === 0) {
|
||
return (
|
||
<div className="flex justify-center items-center min-h-[400px]">
|
||
<div className="text-gray-500">محصولی یافت نشد</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div ref={containerRef} className="bg-WHITE lg:bg-transparent">
|
||
<div className="border-b border-gray-200 px-4 py-3 lg:border-0 lg:px-0 lg:py-0 lg:mb-6 lg:flex lg:items-end lg:justify-between">
|
||
<div>
|
||
<h1 className="text-lg font-semibold text-right lg:text-[24px] lg:font-extrabold">
|
||
محصولات فروشگاه
|
||
</h1>
|
||
<p className="text-sm text-gray-500 mt-1">{products.length} محصول</p>
|
||
</div>
|
||
<button
|
||
onClick={() => navigate(`/store/add/manual/${storeId}`)}
|
||
className="hidden lg:inline-flex items-center gap-2 rounded-xl bg-VITROWN_BLUE text-white px-5 py-2.5 text-[14px] font-bold hover:opacity-90 transition"
|
||
>
|
||
افزودن محصول جدید
|
||
</button>
|
||
</div>
|
||
|
||
{/* Desktop table-style header */}
|
||
<div className="hidden lg:flex items-center px-4 py-3 text-GRAY text-[13px] font-bold border border-WHITE3 rounded-t-2xl bg-WHITE2">
|
||
<span className="flex-1">محصول</span>
|
||
<span className="w-32 text-center">قیمت</span>
|
||
<span className="w-24 text-center">تنوع</span>
|
||
<span className="w-28 text-center">عملیات</span>
|
||
</div>
|
||
|
||
<div className="divide-y divide-gray-200 lg:border lg:border-t-0 lg:border-WHITE3 lg:rounded-b-2xl lg:overflow-hidden lg:bg-WHITE">
|
||
{products.map((product) =>
|
||
product.id ? (
|
||
<ProductAccordionItem
|
||
key={product.id}
|
||
product={product}
|
||
isOpen={openProductId === product.id}
|
||
onToggle={() => handleToggleAccordion(product.id || "")}
|
||
isSticky={stickyProductId === product.id}
|
||
storeId={storeId || ""}
|
||
/>
|
||
) : null
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function StoreProducts() {
|
||
return <StoreProductsContent />;
|
||
}
|