From 5c8e19001c46001fa43c21ec9ed8fb6a04796901 Mon Sep 17 00:00:00 2001 From: Arda Samadi Date: Thu, 9 Jul 2026 12:41:04 +0330 Subject: [PATCH] feat(seller): inline quick-edit for product & variant prices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ("پیش‌فرض: ") - useUpdateProductBasePrice + useUpdateVariantPrice hooks; extend generated API client + types for the two new endpoints Co-Authored-By: Claude Opus 4.8 --- app/components/seller/InlinePriceEditor.tsx | 223 ++++++++++++++++++++ app/requestHandler/use-product-hooks.ts | 105 +++++++++ app/routes/store.$storeId.products.tsx | 208 ++++++++++++------ src/api/types/apis/ProductManagementApi.ts | 88 ++++++++ src/api/types/models/index.ts | 110 +++++++++- 5 files changed, 665 insertions(+), 69 deletions(-) create mode 100644 app/components/seller/InlinePriceEditor.tsx diff --git a/app/components/seller/InlinePriceEditor.tsx b/app/components/seller/InlinePriceEditor.tsx new file mode 100644 index 0000000..ed47206 --- /dev/null +++ b/app/components/seller/InlinePriceEditor.tsx @@ -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(null); + const inputRef = useRef(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 ( +
+
+ 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 + )} + /> +
+ + +
+ ); + } + + /* ---------------------------- display mode ---------------------------- */ + const shown = isPending && optimistic !== null ? optimistic : current; + const hasValue = shown !== ""; + + return ( + + ); +} + +export default InlinePriceEditor; diff --git a/app/requestHandler/use-product-hooks.ts b/app/requestHandler/use-product-hooks.ts index 1c0ea1a..7f2f50b 100644 --- a/app/requestHandler/use-product-hooks.ts +++ b/app/requestHandler/use-product-hooks.ts @@ -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 => { + 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 => { + 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 */ diff --git a/app/routes/store.$storeId.products.tsx b/app/routes/store.$storeId.products.tsx index 93eba20..762af89 100644 --- a/app/routes/store.$storeId.products.tsx +++ b/app/routes/store.$storeId.products.tsx @@ -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( null ); + const [pricingVariantId, setPricingVariantId] = useState(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" )} > - + + {/* Title toggles; price editor sits under it on mobile (not inside a button) */} +
+ +
+ { + if (v) updateBasePrice.mutate({ productId, basePrice: v }); + }} + isPending={updateBasePrice.isPending} + size="sm" + align="start" + ariaLabel="ویرایش قیمت محصول" + className="-mr-2" + />
+ {/* Desktop price column */} - - {product.basePrice - ? `${parseInt(product.basePrice).toLocaleString("fa-IR")} تومان` - : "—"} - +
+ { + if (v) updateBasePrice.mutate({ productId, basePrice: v }); + }} + isPending={updateBasePrice.isPending} + ariaLabel="ویرایش قیمت محصول" + /> +
+ {/* Desktop variants column */} {variants && variants.length > 0 ? ( @@ -94,7 +131,8 @@ function ProductAccordionItem({ )} -
+ +
{variants && variants.length > 0 && ( {variants.length} نوع @@ -102,18 +140,25 @@ function ProductAccordionItem({ )} - {isOpen ? ( - - ) : ( - - )} +
- +
{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 (
-
+
{color && (
)}
-
- { - const variantId = variant.id || ""; - setUpdatingVariantId(variantId); - updateVariantStock.mutate( - { - variantId, - stock: newQuantity, - }, - { - onSettled: () => { - setUpdatingVariantId(null); + +
+ {/* Per-variant price (overrides base price for this variant) */} +
+ قیمت + { + 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" + /> +
+ + {/* Stock */} +
+ موجودی + { + 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} + /> +
); @@ -266,7 +340,7 @@ function StoreProductsContent() { {/* Desktop table-style header */}
محصول - قیمت + قیمت تنوع عملیات
diff --git a/src/api/types/apis/ProductManagementApi.ts b/src/api/types/apis/ProductManagementApi.ts index d0b4b59..a2772e3 100644 --- a/src/api/types/apis/ProductManagementApi.ts +++ b/src/api/types/apis/ProductManagementApi.ts @@ -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> { + 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 { + 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> { + 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 { + 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) diff --git a/src/api/types/models/index.ts b/src/api/types/models/index.ts index 184d479..82777d4 100644 --- a/src/api/types/models/index.ts +++ b/src/api/types/models/index.ts @@ -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 */