Vitron-Front/app/routes/store.$storeId.products.tsx
Arda Samadi 5c8e19001c feat(seller): inline quick-edit for product & variant prices
Sellers can tap a price on the products page to edit it inline (Enter to
save / Escape to cancel), mirroring the existing stock quick-edit.

- new reusable InlinePriceEditor (optimistic display, Persian/Latin digit
  input, thousands grouping, loading + toast feedback, a11y labels)
- base price editable on each product row (mobile + desktop column)
- per-variant price editable beside stock in the expanded panel; empty
  clears the override and inherits the base price ("پیش‌فرض: <base>")
- useUpdateProductBasePrice + useUpdateVariantPrice hooks; extend generated
  API client + types for the two new endpoints

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:41:04 +03:30

369 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useRef, useEffect } from "react";
import { ChevronDown, ChevronUp, Edit } from "lucide-react";
import { useNavigate, useParams } from "@remix-run/react";
import {
useGetSellerProductsList,
useUpdateVariantStock,
useUpdateProductBasePrice,
useUpdateVariantPrice,
} 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";
import { InlinePriceEditor } from "../components/seller/InlinePriceEditor";
/** Format a raw decimal price string to Persian digits ("120000.00" -> "۱۲۰٬۰۰۰"). */
function faPrice(raw: string | null | undefined): string {
if (raw === null || raw === undefined || raw === "") return "";
const n = parseFloat(raw);
if (!isFinite(n)) return "";
return Math.round(n).toLocaleString("fa-IR");
}
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 updateBasePrice = useUpdateProductBasePrice();
const updateVariantPrice = useUpdateVariantPrice();
const [updatingVariantId, setUpdatingVariantId] = useState<string | null>(
null
);
const [pricingVariantId, setPricingVariantId] = useState<string | null>(null);
// Use real variants from API
const variants = product.variants || [];
const productId = product.id || "";
const faBase = faPrice(product.basePrice);
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"
)}
>
<div className="w-full px-4 py-3 flex items-center gap-2 hover:bg-WHITE3 transition-colors">
{/* Image toggles the accordion */}
<button
onClick={onToggle}
aria-label={isOpen ? "بستن" : "نمایش تنوع‌ها"}
className="shrink-0 cursor-pointer"
>
<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"
/>
</button>
{/* Title toggles; price editor sits under it on mobile (not inside a button) */}
<div className="flex-1 min-w-0">
<button
onClick={onToggle}
className="block w-full text-right cursor-pointer"
>
<h3 className="font-medium text-sm lg:text-[15px] truncate">
{product.title}
</h3>
</button>
<div className="mt-0.5 lg:hidden">
<InlinePriceEditor
value={product.basePrice}
onSave={(v) => {
if (v) updateBasePrice.mutate({ productId, basePrice: v });
}}
isPending={updateBasePrice.isPending}
size="sm"
align="start"
ariaLabel="ویرایش قیمت محصول"
className="-mr-2"
/>
</div>
</div>
{/* Desktop price column */}
<div className="hidden lg:flex w-48 justify-center">
<InlinePriceEditor
value={product.basePrice}
onSave={(v) => {
if (v) updateBasePrice.mutate({ productId, basePrice: v });
}}
isPending={updateBasePrice.isPending}
ariaLabel="ویرایش قیمت محصول"
/>
</div>
{/* 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-1 lg:w-28 lg:justify-center shrink-0">
{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 cursor-pointer"
title="ویرایش محصول"
aria-label="ویرایش محصول"
>
<Edit className="w-4 h-4 text-gray-600" />
</button>
<button
onClick={onToggle}
className="p-1.5 hover:bg-gray-200 rounded-lg transition-colors cursor-pointer"
aria-label={isOpen ? "بستن" : "نمایش تنوع‌ها"}
>
{isOpen ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
</div>
</div>
</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"
);
const variantId = variant.id || "";
return (
<div
key={variant.id || `variant-${index}`}
className="flex flex-col gap-3 p-3 bg-WHITE rounded-lg border border-gray-100 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex items-center gap-3">
{color && (
<div
className="w-10 h-10 rounded-sm shadow-sm shrink-0"
style={{
backgroundColor: color.info.detail,
}}
/>
)}
{size && (
<div
className="w-10 h-10 flex items-center justify-center rounded-sm shadow-sm shrink-0"
style={{
backgroundColor: "black",
color: "white",
fontSize: "10px",
}}
>
{size.value}
</div>
)}
</div>
<div className="flex items-center justify-between gap-4 sm:justify-end">
{/* Per-variant price (overrides base price for this variant) */}
<div className="flex flex-col items-start gap-0.5">
<span className="text-[11px] text-GRAY">قیمت</span>
<InlinePriceEditor
value={variant.price}
onSave={(v) => {
setPricingVariantId(variantId);
updateVariantPrice.mutate(
{ variantId, price: v },
{ onSettled: () => setPricingVariantId(null) }
);
}}
isPending={pricingVariantId === variantId}
allowEmpty
placeholder={
faBase ? `پیش‌فرض: ${faBase}` : "پیش‌فرض"
}
size="sm"
align="start"
ariaLabel="ویرایش قیمت این تنوع"
className="-mr-2"
/>
</div>
{/* Stock */}
<div className="flex flex-col items-end gap-0.5">
<span className="text-[11px] text-GRAY">موجودی</span>
<ThrottledQuantityControl
key={`${variant.id}-${variant.stock}`}
initialQuantity={variant.stock || 0}
maxQuantity={999}
onQuantityUpdate={(newQuantity) => {
setUpdatingVariantId(variantId);
updateVariantStock.mutate(
{
variantId,
stock: newQuantity,
},
{
onSettled: () => {
setUpdatingVariantId(null);
},
}
);
}}
isPending={updatingVariantId === variant.id}
allowManualEdit={true}
throttleDelay={1000}
/>
</div>
</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-48 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 />;
}