The pencil hint was hover-only, so on phones the price read as plain text. Style the display as an editable field (subtle border + grey fill) with an always-visible pencil, and drop the negative margins that fought the border. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
228 lines
7.2 KiB
TypeScript
228 lines
7.2 KiB
TypeScript
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}
|
||
// Looks like an editable field (subtle border + grey fill) with an
|
||
// always-visible pencil, so it reads as tappable on touch devices where
|
||
// there is no hover to reveal the affordance.
|
||
className={cn(
|
||
"group inline-flex items-center gap-1.5 rounded-lg border border-GRAY3 bg-WHITE3 px-2.5 py-1.5 min-h-[36px]",
|
||
"transition-colors hover:border-VITROWN_BLUE hover:bg-VITROWN_BLUE/5 active:bg-VITROWN_BLUE/10",
|
||
"cursor-pointer disabled:cursor-default disabled:opacity-70",
|
||
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-VITROWN_BLUE/70 transition-colors group-hover:text-VITROWN_BLUE" />
|
||
)}
|
||
{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;
|