Vitron-Front/app/components/seller/InlinePriceEditor.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

224 lines
6.9 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 { 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;