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>
This commit is contained in:
parent
16389fdd85
commit
5c8e19001c
223
app/components/seller/InlinePriceEditor.tsx
Normal file
223
app/components/seller/InlinePriceEditor.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Loader2, Pencil, X } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
/* ---------- digit helpers (accept both Persian & Latin input) ---------- */
|
||||
|
||||
const FA_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
|
||||
const AR_DIGITS = "٠١٢٣٤٥٦٧٨٩";
|
||||
|
||||
function toLatinDigits(input: string): string {
|
||||
return input
|
||||
.replace(/[۰-۹]/g, (d) => String(FA_DIGITS.indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String(AR_DIGITS.indexOf(d)));
|
||||
}
|
||||
|
||||
/** Extract only the integer digits from any incoming value ("120000.00" -> "120000"). */
|
||||
function normalizeIncoming(raw: string | null | undefined): string {
|
||||
if (raw === null || raw === undefined || raw === "") return "";
|
||||
const n = parseFloat(toLatinDigits(String(raw)));
|
||||
if (!isFinite(n) || n <= 0) return "";
|
||||
return String(Math.round(n));
|
||||
}
|
||||
|
||||
/** Keep only digits typed by the user (drops separators, letters, Persian digits normalized). */
|
||||
function onlyDigits(input: string): string {
|
||||
return toLatinDigits(input).replace(/[^\d]/g, "");
|
||||
}
|
||||
|
||||
/** Group with thousands separators for the edit input ("120000" -> "120,000"). */
|
||||
function groupLatin(digits: string): string {
|
||||
if (!digits) return "";
|
||||
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
/** Persian-grouped display ("120000" -> "۱۲۰٬۰۰۰"). */
|
||||
function toFaDisplay(digits: string): string {
|
||||
if (!digits) return "";
|
||||
return Number(digits).toLocaleString("fa-IR");
|
||||
}
|
||||
|
||||
/* ------------------------------- component ------------------------------ */
|
||||
|
||||
interface InlinePriceEditorProps {
|
||||
/** Current raw price (decimal string) or null/"" when unset. */
|
||||
value: string | null | undefined;
|
||||
/** Called with the new integer price string, or null when cleared (only if allowEmpty). */
|
||||
onSave: (value: string | null) => void;
|
||||
isPending?: boolean;
|
||||
/** Allow saving an empty value as null (e.g. a variant inheriting the base price). */
|
||||
allowEmpty?: boolean;
|
||||
/** Muted hint shown when there is no value and we're not editing (e.g. inherited price). */
|
||||
placeholder?: string;
|
||||
suffix?: string;
|
||||
size?: "sm" | "md";
|
||||
align?: "start" | "center" | "end";
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlinePriceEditor({
|
||||
value,
|
||||
onSave,
|
||||
isPending = false,
|
||||
allowEmpty = false,
|
||||
placeholder,
|
||||
suffix = "تومان",
|
||||
size = "md",
|
||||
align = "end",
|
||||
ariaLabel = "ویرایش قیمت",
|
||||
className,
|
||||
}: InlinePriceEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(""); // raw digits while editing
|
||||
// Optimistic value shown during the async save so the number doesn't flicker
|
||||
// back to the old server value before the refetch lands.
|
||||
const [optimistic, setOptimistic] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const current = normalizeIncoming(value);
|
||||
|
||||
// Clear the optimistic value once the mutation settles.
|
||||
useEffect(() => {
|
||||
if (!isPending) setOptimistic(null);
|
||||
}, [isPending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const openEditor = () => {
|
||||
if (isPending) return;
|
||||
setDraft(current);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
const next = onlyDigits(draft);
|
||||
// No-op if unchanged.
|
||||
if (next === current) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
if (!next) {
|
||||
if (allowEmpty) {
|
||||
setOptimistic("");
|
||||
onSave(null);
|
||||
} else {
|
||||
// Empty not allowed (e.g. base price) → discard.
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setOptimistic(next);
|
||||
onSave(next);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setDraft(current);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const sizeText = size === "sm" ? "text-[13px]" : "text-[14px]";
|
||||
const alignClass =
|
||||
align === "center"
|
||||
? "justify-center text-center"
|
||||
: align === "start"
|
||||
? "justify-start text-right"
|
||||
: "justify-end text-right";
|
||||
|
||||
/* ------------------------------ edit mode ----------------------------- */
|
||||
if (editing) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center gap-1.5", alignClass, className)}
|
||||
dir="ltr"
|
||||
>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label={ariaLabel}
|
||||
value={groupLatin(draft)}
|
||||
onChange={(e) => setDraft(onlyDigits(e.target.value))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
placeholder={allowEmpty ? "پیشفرض" : "قیمت"}
|
||||
className={cn(
|
||||
"w-24 rounded-lg border border-VITROWN_BLUE bg-WHITE px-2 py-2 text-left tabular-nums outline-none",
|
||||
"focus:ring-2 focus:ring-VITROWN_BLUE/30 transition-shadow",
|
||||
sizeText
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={commit}
|
||||
aria-label="ذخیره قیمت"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg bg-VITROWN_BLUE text-WHITE transition-colors hover:opacity-90 cursor-pointer"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
aria-label="انصراف"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg bg-WHITE3 text-BLACK2 transition-colors hover:bg-GRAY3 cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------- display mode ---------------------------- */
|
||||
const shown = isPending && optimistic !== null ? optimistic : current;
|
||||
const hasValue = shown !== "";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openEditor}
|
||||
disabled={isPending}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"group inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 min-h-[36px]",
|
||||
"transition-colors hover:bg-VITROWN_BLUE/5 cursor-pointer disabled:cursor-default",
|
||||
alignClass,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-VITROWN_BLUE" />
|
||||
) : (
|
||||
<Pencil className="h-3.5 w-3.5 shrink-0 text-GRAY opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
{hasValue ? (
|
||||
<span className={cn("font-semibold tabular-nums", sizeText)}>
|
||||
{toFaDisplay(shown)}
|
||||
<span className="mr-1 text-GRAY font-normal">{suffix}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn("text-GRAY", sizeText)}>
|
||||
{placeholder ?? "افزودن قیمت"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default InlinePriceEditor;
|
||||
@ -14,6 +14,10 @@ import {
|
||||
SellerProductListItem,
|
||||
VariantStockUpdateRequest,
|
||||
VariantStockUpdateResponse,
|
||||
ProductBasePriceUpdateRequest,
|
||||
ProductBasePriceUpdateResponse,
|
||||
VariantPriceUpdateRequest,
|
||||
VariantPriceUpdateResponse,
|
||||
PaginatedSimilarProduct,
|
||||
} from "../../src/api/types";
|
||||
import { useToast } from "../hooks/use-toast";
|
||||
@ -366,6 +370,107 @@ export const useUpdateVariantStock = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to update a product's base price (quick edit).
|
||||
*/
|
||||
export const useUpdateProductBasePrice = () => {
|
||||
const token = useAuthToken();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
productId,
|
||||
basePrice,
|
||||
}: {
|
||||
productId: string;
|
||||
basePrice: string;
|
||||
}): Promise<ProductBasePriceUpdateResponse> => {
|
||||
if (!token) throw new Error("Authentication required");
|
||||
|
||||
const productApi = createProductManagementApi(token);
|
||||
const data: ProductBasePriceUpdateRequest = {
|
||||
productId,
|
||||
basePrice,
|
||||
};
|
||||
return await productApi.apiProductV1ProductsBasePricePartialUpdate({
|
||||
data,
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast({
|
||||
title: "موفق",
|
||||
description: "قیمت با موفقیت بهروزرسانی شد",
|
||||
variant: "default",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["sellerProductsList"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
if (data.id) {
|
||||
queryClient.invalidateQueries({ queryKey: ["product", data.id] });
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Base price update failed:", error);
|
||||
toast({
|
||||
title: "خطا",
|
||||
description: "خطا در بهروزرسانی قیمت. لطفاً دوباره تلاش کنید",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to update a single variant's price override (quick edit).
|
||||
* Pass `price: null` to clear the override and inherit the base price.
|
||||
*/
|
||||
export const useUpdateVariantPrice = () => {
|
||||
const token = useAuthToken();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
variantId,
|
||||
price,
|
||||
}: {
|
||||
variantId: string;
|
||||
price: string | null;
|
||||
}): Promise<VariantPriceUpdateResponse> => {
|
||||
if (!token) throw new Error("Authentication required");
|
||||
|
||||
const productApi = createProductManagementApi(token);
|
||||
const data: VariantPriceUpdateRequest = {
|
||||
variantId,
|
||||
price,
|
||||
};
|
||||
return await productApi.apiProductV1ProductsVariantsPricePartialUpdate({
|
||||
data,
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast({
|
||||
title: "موفق",
|
||||
description: "قیمت با موفقیت بهروزرسانی شد",
|
||||
variant: "default",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["sellerProductsList"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
if (data.product) {
|
||||
queryClient.invalidateQueries({ queryKey: ["product", data.product] });
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Variant price update failed:", error);
|
||||
toast({
|
||||
title: "خطا",
|
||||
description: "خطا در بهروزرسانی قیمت. لطفاً دوباره تلاش کنید",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to fetch similar products for a given product ID
|
||||
*/
|
||||
|
||||
@ -4,6 +4,8 @@ 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";
|
||||
@ -11,6 +13,15 @@ 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,
|
||||
@ -28,11 +39,16 @@ function ProductAccordionItem({
|
||||
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();
|
||||
@ -47,43 +63,64 @@ function ProductAccordionItem({
|
||||
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">
|
||||
<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>
|
||||
{/* 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>
|
||||
)}
|
||||
</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 */}
|
||||
<span className="hidden lg:block w-32 text-center text-[14px] font-semibold">
|
||||
{product.basePrice
|
||||
? `${parseInt(product.basePrice).toLocaleString("fa-IR")} تومان`
|
||||
: "—"}
|
||||
</span>
|
||||
<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 ? (
|
||||
@ -94,7 +131,8 @@ function ProductAccordionItem({
|
||||
<span className="text-GRAY">—</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 lg:w-28 lg:justify-center">
|
||||
|
||||
<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} نوع
|
||||
@ -102,18 +140,25 @@ function ProductAccordionItem({
|
||||
)}
|
||||
<button
|
||||
onClick={handleEditProduct}
|
||||
className="p-1.5 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
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>
|
||||
{isOpen ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && variants && variants.length > 0 && (
|
||||
@ -126,15 +171,16 @@ function ProductAccordionItem({
|
||||
const size = variant.attributes?.find(
|
||||
(attribute) => attribute.name === "SIZE"
|
||||
);
|
||||
const variantId = variant.id || "";
|
||||
return (
|
||||
<div
|
||||
key={variant.id || `variant-${index}`}
|
||||
className="flex items-center justify-between p-3 bg-WHITE rounded-lg border border-gray-100"
|
||||
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 justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{color && (
|
||||
<div
|
||||
className="w-10 h-10 rounded-sm shadow-sm"
|
||||
className="w-10 h-10 rounded-sm shadow-sm shrink-0"
|
||||
style={{
|
||||
backgroundColor: color.info.detail,
|
||||
}}
|
||||
@ -142,7 +188,7 @@ function ProductAccordionItem({
|
||||
)}
|
||||
{size && (
|
||||
<div
|
||||
className="w-10 h-10 flex items-center justify-center rounded-sm shadow-sm"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-sm shadow-sm shrink-0"
|
||||
style={{
|
||||
backgroundColor: "black",
|
||||
color: "white",
|
||||
@ -153,30 +199,58 @@ function ProductAccordionItem({
|
||||
</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);
|
||||
|
||||
<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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
isPending={updatingVariantId === variant.id}
|
||||
allowManualEdit={true}
|
||||
throttleDelay={1000}
|
||||
/>
|
||||
{
|
||||
onSettled: () => {
|
||||
setUpdatingVariantId(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
isPending={updatingVariantId === variant.id}
|
||||
allowManualEdit={true}
|
||||
throttleDelay={1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -266,7 +340,7 @@ function StoreProductsContent() {
|
||||
{/* 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-48 text-center">قیمت</span>
|
||||
<span className="w-24 text-center">تنوع</span>
|
||||
<span className="w-28 text-center">عملیات</span>
|
||||
</div>
|
||||
|
||||
@ -31,6 +31,10 @@ import type {
|
||||
SellerProductListItem,
|
||||
VariantStockUpdateRequest,
|
||||
VariantStockUpdateResponse,
|
||||
ProductBasePriceUpdateRequest,
|
||||
ProductBasePriceUpdateResponse,
|
||||
VariantPriceUpdateRequest,
|
||||
VariantPriceUpdateResponse,
|
||||
} from '../models';
|
||||
|
||||
export interface ApiProductV1AttributeCreateRequest {
|
||||
@ -105,6 +109,14 @@ export interface ApiProductV1ProductsVariantsStockPartialUpdateRequest {
|
||||
data: VariantStockUpdateRequest;
|
||||
}
|
||||
|
||||
export interface ApiProductV1ProductsBasePricePartialUpdateRequest {
|
||||
data: ProductBasePriceUpdateRequest;
|
||||
}
|
||||
|
||||
export interface ApiProductV1ProductsVariantsPricePartialUpdateRequest {
|
||||
data: VariantPriceUpdateRequest;
|
||||
}
|
||||
|
||||
export interface ApiProductV1SellerProductsListRequest {
|
||||
search?: string;
|
||||
ordering?: string;
|
||||
@ -807,6 +819,82 @@ export class ProductManagementApi extends runtime.BaseAPI {
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update **only** the base price of a product owned by (or managed for) the authenticated seller. Body includes `product_id` and the new `base_price`.
|
||||
* Update product base price (seller only)
|
||||
*/
|
||||
async apiProductV1ProductsBasePricePartialUpdateRaw(requestParameters: ApiProductV1ProductsBasePricePartialUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ProductBasePriceUpdateResponse>> {
|
||||
if (requestParameters.data === null || requestParameters.data === undefined) {
|
||||
throw new runtime.RequiredError('data','Required parameter requestParameters.data was null or undefined when calling apiProductV1ProductsBasePricePartialUpdate.');
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
headerParameters['Content-Type'] = 'application/json';
|
||||
|
||||
if (this.configuration && (this.configuration.username !== undefined || this.configuration.password !== undefined)) {
|
||||
headerParameters["Authorization"] = "Basic " + btoa(this.configuration.username + ":" + this.configuration.password);
|
||||
}
|
||||
const response = await this.request({
|
||||
path: `/api/product/v1/products/base-price/`,
|
||||
method: 'PATCH',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
body: requestParameters.data,
|
||||
}, initOverrides);
|
||||
|
||||
return new runtime.JSONApiResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update **only** the base price of a product owned by (or managed for) the authenticated seller. Body includes `product_id` and the new `base_price`.
|
||||
* Update product base price (seller only)
|
||||
*/
|
||||
async apiProductV1ProductsBasePricePartialUpdate(requestParameters: ApiProductV1ProductsBasePricePartialUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ProductBasePriceUpdateResponse> {
|
||||
const response = await this.apiProductV1ProductsBasePricePartialUpdateRaw(requestParameters, initOverrides);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update **only** the per-variant price override of a product variant owned by (or managed for) the authenticated seller. Body includes `variant_id` and the new `price` (or `null` to inherit the product base price).
|
||||
* Update variant price (seller only)
|
||||
*/
|
||||
async apiProductV1ProductsVariantsPricePartialUpdateRaw(requestParameters: ApiProductV1ProductsVariantsPricePartialUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<VariantPriceUpdateResponse>> {
|
||||
if (requestParameters.data === null || requestParameters.data === undefined) {
|
||||
throw new runtime.RequiredError('data','Required parameter requestParameters.data was null or undefined when calling apiProductV1ProductsVariantsPricePartialUpdate.');
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
headerParameters['Content-Type'] = 'application/json';
|
||||
|
||||
if (this.configuration && (this.configuration.username !== undefined || this.configuration.password !== undefined)) {
|
||||
headerParameters["Authorization"] = "Basic " + btoa(this.configuration.username + ":" + this.configuration.password);
|
||||
}
|
||||
const response = await this.request({
|
||||
path: `/api/product/v1/products/variants/price/`,
|
||||
method: 'PATCH',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
body: requestParameters.data,
|
||||
}, initOverrides);
|
||||
|
||||
return new runtime.JSONApiResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update **only** the per-variant price override of a product variant owned by (or managed for) the authenticated seller. Body includes `variant_id` and the new `price` (or `null` to inherit the product base price).
|
||||
* Update variant price (seller only)
|
||||
*/
|
||||
async apiProductV1ProductsVariantsPricePartialUpdate(requestParameters: ApiProductV1ProductsVariantsPricePartialUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<VariantPriceUpdateResponse> {
|
||||
const response = await this.apiProductV1ProductsVariantsPricePartialUpdateRaw(requestParameters, initOverrides);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the authenticated seller\'s products with full attributes & variants. Ordering is strictly stable: - Products: -id (newest first) - Variants: id (ascending) - VariantAttributeMapping: id (ascending)
|
||||
* List seller-owned products (stable by id)
|
||||
|
||||
@ -6997,14 +6997,120 @@ export interface VariantStockUpdateResponse {
|
||||
*/
|
||||
readonly stock?: number;
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof VariantStockUpdateResponse
|
||||
*/
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @export
|
||||
* @interface ProductBasePriceUpdateRequest
|
||||
*/
|
||||
export interface ProductBasePriceUpdateRequest {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ProductBasePriceUpdateRequest
|
||||
*/
|
||||
productId: string;
|
||||
/**
|
||||
* New base price (>= 0)
|
||||
* @type {string}
|
||||
* @memberof ProductBasePriceUpdateRequest
|
||||
*/
|
||||
basePrice: string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface ProductBasePriceUpdateResponse
|
||||
*/
|
||||
export interface ProductBasePriceUpdateResponse {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ProductBasePriceUpdateResponse
|
||||
*/
|
||||
readonly id?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ProductBasePriceUpdateResponse
|
||||
*/
|
||||
readonly basePrice?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ProductBasePriceUpdateResponse
|
||||
*/
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface VariantPriceUpdateRequest
|
||||
*/
|
||||
export interface VariantPriceUpdateRequest {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof VariantPriceUpdateRequest
|
||||
*/
|
||||
variantId: string;
|
||||
/**
|
||||
* Per-variant price override (>= 0), or null to inherit base price
|
||||
* @type {string}
|
||||
* @memberof VariantPriceUpdateRequest
|
||||
*/
|
||||
price: string | null;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface VariantPriceUpdateResponse
|
||||
*/
|
||||
export interface VariantPriceUpdateResponse {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof VariantPriceUpdateResponse
|
||||
*/
|
||||
readonly id?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof VariantPriceUpdateResponse
|
||||
*/
|
||||
readonly product?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof VariantPriceUpdateResponse
|
||||
*/
|
||||
readonly variantName?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof VariantPriceUpdateResponse
|
||||
*/
|
||||
readonly price?: string | null;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof VariantPriceUpdateResponse
|
||||
*/
|
||||
readonly discountPrice?: string | null;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof VariantPriceUpdateResponse
|
||||
*/
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface VectorSearchRequest
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user