Vitron-Front/app/components/seller/InlinePriceEditor.tsx
Arda Samadi 72e4baab10 fix(seller): make inline price editor obviously tappable on touch
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>
2026-07-09 12:51:36 +03:30

228 lines
7.2 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}
// 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;