food-market/src/food-market.web/src/components/PhoneInput.tsx
nns 6510ccafb5
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Public / Build + push Public (test + latest) (push) Waiting to run
Docker Public / Deploy :test → test.food-market.kz (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
fix(регистрация): вставка телефона + 3 мелочи перепроверки (12.10)
Маска телефона: onBeforeInput больше не блокирует ВСТАВКУ. Раньше он
отменял любой ввод с не-цифрами в data, из-за чего вставка «+7 701…» /
«8 (701) 000-00-00» через CDP insertText блокировалась целиком и номер
пропадал («+7 »). Теперь блокируется только одиночный набранный не-цифровой
символ; вставку нормализует handleChange. Обе копии (public + admin).
Проверено в реальном Chromium (Playwright): посимвольный ввод (delay 0 и 20),
все варианты вставки и Backspace посреди номера → «+7 701 000 00 00».

1. Мастер «Первые шаги», шаг каталога: число товаров с разделителем разрядов
   и склонением («30 951 товар»); текст исправлен — закупочная и себестоимость
   приходят из реестра вместе с товаром, оставлено «Проверьте продажные цены —
   они взяты из реестра».
2. Прибыль на главной: убрана фраза про «переход на FIFO» (внутренняя кухня);
   оставлено «Прибыль приблизительная — по текущей себестоимости из карточек».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 22:10:20 +05:00

164 lines
8.5 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 { useMemo, useRef, useLayoutEffect, type InputHTMLAttributes, type FormEvent } from 'react'
import { cn } from '@/lib/utils'
const inputClass = 'w-full h-10 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60 disabled:bg-slate-50 dark:disabled:bg-slate-800/60 tabular-nums'
// Код страны (+7) живёт в префиксе; оператор НЕ подставляем заранее, иначе
// при вводе полного номера «7XX…» его первая цифра налезает на префиксную «7».
const PREFIX = '+7 '
const PREFIX_LEN = PREFIX.length // 3
// Национальный номер KZ — 10 цифр, начинается с 7 (например 701 000 00 00).
// Из любой формы вытаскиваем до 10 цифр нац. номера: снимаем ведущую «8»
// (межгород) и, если получилось 11 цифр с ведущей «7» — это код страны, снимаем.
// Прим.: при посимвольном вводе в handleChange сюда передаётся ТОЛЬКО часть
// после префикса, чтобы «7» из «+7 » не попала в номер.
function toNational(raw: string): string {
let d = raw.replace(/\D/g, '')
if (d[0] === '8') d = d.slice(1)
if (d.length === 11 && d[0] === '7') d = d.slice(1)
return d.slice(0, 10)
}
// Восстановление нац. номера из каноничного value. Мы ВСЕГДА эмитим value как
// «+7»+national, поэтому снимаем ровно литеральный код страны «+7» и не
// применяем 8/11-эвристики toNational — иначе «7» кода страны при неполном
// номере сливается с номером и ввод «скачет» (+7 77, +7 777 0, …).
function valueToNational(value: string): string {
if (value.startsWith('+7')) return value.slice(2).replace(/\D/g, '').slice(0, 10)
return toNational(value)
}
// Display из нац. номера. Формат: «+7 7XX XXX XX XX» — после «+7 » идут
// 3+3+2+2 = 10 цифр через пробелы.
function nationalToDisplay(d: string): string {
if (d.length === 0) return PREFIX
let s = PREFIX + d.slice(0, 3)
if (d.length > 3) s += ' ' + d.slice(3, 6)
if (d.length > 6) s += ' ' + d.slice(6, 8)
if (d.length > 8) s += ' ' + d.slice(8, 10)
return s
}
// Курсор считаем в цифрах нац. номера (цифры ПОСЛЕ префикса), чтобы «7» из
// «+7 » не сбивала позицию.
function natDigitsBeforeCursor(value: string, cursor: number): number {
let count = 0
for (let i = PREFIX_LEN; i < cursor && i < value.length; i++) {
if (/\d/.test(value[i])) count++
}
return count
}
// Позиция в value сразу ПОСЛЕ n-й цифры нац. номера (1-indexed). Если n<=0 —
// начало digit-зоны (сразу за префиксом); если больше чем есть — конец строки.
function cursorAfterNthNatDigit(value: string, n: number): number {
if (n <= 0) return PREFIX_LEN
let count = 0
for (let i = PREFIX_LEN; i < value.length; i++) {
if (/\d/.test(value[i])) {
count++
if (count === n) return i + 1
}
}
return value.length
}
interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> {
/** Каноничное значение «+77XXXXXXXXX» либо пустая строка. */
value: string
onChange: (value: string) => void
}
/** Поле ввода телефона Казахстана. Префикс «+7 » (3 символа) залочен — курсор
* не пускается в позиции 0..2, выделение клампится на digit-зону. В остальном
* работает как обычный input: native selection, backspace, click, Cmd+A,
* paste, drag. Не-цифры блокируются через onBeforeInput. Нормализация снимает
* ведущую «8» и код страны «7», поэтому «8701…», «+7701…», «7701…» и «701…»
* дают один и тот же номер «+7 701 …». */
export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) {
const ref = useRef<HTMLInputElement>(null)
const national = useMemo(() => valueToNational(value), [value])
const display = nationalToDisplay(national)
// Запоминаем сколько цифр нац. номера стояло до курсора в момент
// редактирования — после React-нормализации возвращаем курсор после той же
// по счёту цифры, иначе React сбрасывал бы его в конец на каждом изменении.
const pendingDigitCountRef = useRef<number | null>(null)
useLayoutEffect(() => {
const n = pendingDigitCountRef.current
pendingDigitCountRef.current = null
if (n === null) return
const el = ref.current
if (!el) return
const pos = Math.max(PREFIX_LEN, cursorAfterNthNatDigit(display, n))
el.setSelectionRange(pos, pos)
}, [display])
// Блокируем вставку не-цифр (типинг + paste + IME). data === null для
// deleteContentBackward и т.п. — там не вмешиваемся, пусть браузер удаляет.
const handleBeforeInput = (e: FormEvent<HTMLInputElement>) => {
const native = e.nativeEvent as InputEvent
// Блокируем ТОЛЬКО одиночный набранный не-цифровой символ. Вставку
// (insertFromPaste или insertText с многосимвольной строкой) пропускаем —
// её нормализует handleChange, иначе вставка «+7 701…» / «8 (701)…»
// блокировалась целиком и номер пропадал.
if (native.inputType === 'insertText'
&& typeof native.data === 'string'
&& native.data.length === 1
&& /\D/.test(native.data)) {
e.preventDefault()
}
}
// После любого нативного редактирования (печать/удаление/paste/drag-drop)
// нормализуем значение. Запоминаем позицию курсора в digit-индексах,
// чтобы useLayoutEffect восстановил её после ре-рендера.
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
const cursorPos = e.target.selectionStart ?? raw.length
pendingDigitCountRef.current = natDigitsBeforeCursor(raw, cursorPos)
// При посимвольном вводе значение начинается с «+7 » — берём часть после
// префикса, чтобы его «7» не попала в номер. При полной вставке значения
// без префикса нормализуем целиком (снимем код страны/«8»).
const src = raw.startsWith(PREFIX) ? raw.slice(PREFIX_LEN) : raw
const nat = toNational(src)
onChange(nat.length > 0 ? '+7' + nat : '')
}
// Не пускаем курсор/selection в префикс. onSelect ловит любые попытки
// (клик, стрелки, Cmd+A) и мягко двигает на digit-зону.
const guardCursor = (el: HTMLInputElement) => {
const start = el.selectionStart ?? 0
const end = el.selectionEnd ?? 0
if (start < PREFIX_LEN || end < PREFIX_LEN) {
el.setSelectionRange(Math.max(PREFIX_LEN, start), Math.max(PREFIX_LEN, end))
}
}
const handleSelect = (e: React.SyntheticEvent<HTMLInputElement>) => guardCursor(e.currentTarget)
const handleFocus = () => {
const el = ref.current
if (!el) return
requestAnimationFrame(() => guardCursor(el))
}
return (
<input
ref={ref}
type="tel"
inputMode="tel"
autoComplete="tel"
value={display}
onBeforeInput={handleBeforeInput}
onChange={handleChange}
onSelect={handleSelect}
onFocus={handleFocus}
disabled={disabled}
placeholder={PREFIX}
className={cn(inputClass, className)}
{...rest}
/>
)
}