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
Маска телефона: 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>
164 lines
8.5 KiB
TypeScript
164 lines
8.5 KiB
TypeScript
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}
|
||
/>
|
||
)
|
||
}
|