Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
236 lines
7.8 KiB
TypeScript
236 lines
7.8 KiB
TypeScript
'use client'
|
|
|
|
import type { KeyboardEvent } from 'react'
|
|
|
|
import { Controller, type Control } from 'react-hook-form'
|
|
|
|
import ConsumerFieldShell from '@/components/consumer/input/ConsumerFieldShell'
|
|
import type { ConsumerInputRadius, ConsumerInputSize, ConsumerInputTone } from '@/components/consumer/input/consumerInputTypes'
|
|
import {
|
|
CONSUMER_FIELD_INPUT_CLASS,
|
|
buildConsumerFieldWrapper,
|
|
resolveConsumerTone,
|
|
withConsumerFieldError,
|
|
} from '@/components/consumer/input/consumerInputStyles'
|
|
import { buildConsumerAmountDescription } from '@/components/consumer/input/consumerInputUtils'
|
|
import { Input } from '@/components/heroui/Input'
|
|
import { NumberInput } from '@/components/heroui/NumberInput'
|
|
import { formatNumberInputDisplayValue } from '@/components/formElements/input/inputUtils'
|
|
import { convertPersianToEnglish } from '@/helpers'
|
|
import { cn } from '@/lib/cn'
|
|
|
|
interface SharedProps {
|
|
ariaLabel?: string
|
|
autoFocus?: boolean
|
|
className?: string
|
|
radius: ConsumerInputRadius
|
|
size: ConsumerInputSize
|
|
control: Control
|
|
description?: React.ReactNode
|
|
direction: 'rtl' | 'ltr'
|
|
disabled?: boolean
|
|
englishDigitsOnly: boolean
|
|
formatOptions?: Intl.NumberFormatOptions
|
|
iconEnd?: React.ReactNode
|
|
iconStart?: React.ReactNode
|
|
inputWrapper?: string
|
|
inputType?: 'text' | 'email' | 'number' | 'password' | 'tel'
|
|
isClearable: boolean
|
|
label?: string
|
|
labelIcon?: React.ReactNode
|
|
maxValue?: number
|
|
minValue?: number
|
|
name: string
|
|
placeholder?: string
|
|
readonly?: boolean
|
|
required?: boolean
|
|
tone?: ConsumerInputTone
|
|
onBlur?: () => void
|
|
onClear?: () => void
|
|
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void
|
|
}
|
|
|
|
export function ConsumerTextField(props: SharedProps) {
|
|
const tone = resolveConsumerTone('input', props.tone)
|
|
const wrapper = cn(
|
|
buildConsumerFieldWrapper({
|
|
radius: props.radius,
|
|
size: props.size,
|
|
tone,
|
|
}),
|
|
props.inputWrapper
|
|
)
|
|
|
|
return (
|
|
<Controller
|
|
control={props.control}
|
|
name={props.name}
|
|
render={({ field, fieldState: { error } }) => {
|
|
const invalid = !!error
|
|
const inputWrapper = invalid ? withConsumerFieldError(wrapper) : wrapper
|
|
const description = buildConsumerAmountDescription(field.value, props.label, props.description)
|
|
const isTel = props.inputType === 'tel'
|
|
const canClear = props.isClearable && !props.readonly && props.inputType !== 'password' && !isTel
|
|
const isRtlLayout = props.direction === 'rtl' && !isTel
|
|
|
|
return (
|
|
<ConsumerFieldShell
|
|
className={props.className}
|
|
description={description}
|
|
errorMessage={error?.message}
|
|
invalid={invalid}
|
|
label={props.label}
|
|
labelIcon={props.labelIcon}
|
|
required={props.required}
|
|
>
|
|
<Input
|
|
{...field}
|
|
aria-label={props.label ? undefined : props.ariaLabel}
|
|
autoFocus={props.autoFocus}
|
|
classNames={{
|
|
innerWrapper: isRtlLayout ? 'flex-row-reverse' : '',
|
|
input: cn(CONSUMER_FIELD_INPUT_CLASS, isRtlLayout ? 'text-right' : isTel ? undefined : 'text-left'),
|
|
inputWrapper,
|
|
}}
|
|
dir={isTel ? 'ltr' : undefined}
|
|
endContent={props.iconEnd}
|
|
inputMode={isTel ? 'tel' : props.englishDigitsOnly ? 'numeric' : undefined}
|
|
isClearable={canClear}
|
|
isDisabled={props.disabled}
|
|
isInvalid={invalid}
|
|
isReadOnly={props.readonly}
|
|
max={props.maxValue}
|
|
min={props.minValue}
|
|
pattern={props.englishDigitsOnly ? '[0-9]*' : undefined}
|
|
placeholder={props.placeholder}
|
|
size={props.size}
|
|
startContent={props.iconStart}
|
|
type={props.inputType}
|
|
validationBehavior="aria"
|
|
variant="bordered"
|
|
onBlur={() => {
|
|
field.onBlur()
|
|
props.onBlur?.()
|
|
}}
|
|
onChange={(event) => {
|
|
let value = event.target.value
|
|
|
|
if (props.englishDigitsOnly || isTel) value = convertPersianToEnglish(value).replace(/\D/g, '')
|
|
else if (props.inputType === 'number') value = String(Number(value))
|
|
field.onChange(value)
|
|
}}
|
|
onKeyDown={props.onKeyDown}
|
|
{...(canClear
|
|
? {
|
|
onClear: () => {
|
|
if (props.onClear) props.onClear()
|
|
else field.onChange('')
|
|
},
|
|
}
|
|
: {})}
|
|
/>
|
|
</ConsumerFieldShell>
|
|
)
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function ConsumerNumberField(props: SharedProps) {
|
|
const tone = resolveConsumerTone('numberInput', props.tone)
|
|
const wrapper = cn(
|
|
buildConsumerFieldWrapper({
|
|
radius: props.radius,
|
|
size: props.size,
|
|
tone,
|
|
}),
|
|
props.inputWrapper
|
|
)
|
|
|
|
return (
|
|
<Controller
|
|
control={props.control}
|
|
name={props.name}
|
|
render={({ field, fieldState: { error } }) => {
|
|
const invalid = !!error
|
|
const inputWrapper = invalid ? withConsumerFieldError(wrapper) : wrapper
|
|
const description = buildConsumerAmountDescription(field.value, props.label, props.description)
|
|
const { onChange, value, ...fieldRest } = field
|
|
|
|
if (props.readonly) {
|
|
return (
|
|
<ConsumerFieldShell
|
|
className={props.className}
|
|
description={description}
|
|
errorMessage={error?.message}
|
|
invalid={invalid}
|
|
label={props.label}
|
|
labelIcon={props.labelIcon}
|
|
required={props.required}
|
|
>
|
|
<Input
|
|
{...fieldRest}
|
|
isReadOnly
|
|
aria-label={props.ariaLabel}
|
|
classNames={{
|
|
innerWrapper: props.direction,
|
|
input: CONSUMER_FIELD_INPUT_CLASS,
|
|
inputWrapper,
|
|
}}
|
|
endContent={props.iconEnd}
|
|
isDisabled={props.disabled}
|
|
isInvalid={invalid}
|
|
placeholder={props.placeholder}
|
|
size={props.size}
|
|
startContent={props.iconStart}
|
|
type="text"
|
|
validationBehavior="aria"
|
|
value={formatNumberInputDisplayValue(value, props.formatOptions)}
|
|
variant="bordered"
|
|
/>
|
|
</ConsumerFieldShell>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ConsumerFieldShell
|
|
className={props.className}
|
|
description={description}
|
|
errorMessage={error?.message}
|
|
invalid={invalid}
|
|
label={props.label}
|
|
labelIcon={props.labelIcon}
|
|
required={props.required}
|
|
>
|
|
<NumberInput
|
|
{...fieldRest}
|
|
classNames={{ input: CONSUMER_FIELD_INPUT_CLASS, inputWrapper }}
|
|
endContent={props.iconEnd}
|
|
formatOptions={props.formatOptions}
|
|
isClearable={props.isClearable}
|
|
isDisabled={props.disabled}
|
|
isInvalid={invalid}
|
|
maxValue={props.maxValue}
|
|
minValue={props.minValue}
|
|
size={props.size}
|
|
startContent={props.iconStart}
|
|
validationBehavior="aria"
|
|
value={value ?? undefined}
|
|
variant="bordered"
|
|
{...(props.isClearable
|
|
? {
|
|
onClear: () => {
|
|
if (props.onClear) props.onClear()
|
|
else onChange(undefined)
|
|
},
|
|
}
|
|
: {})}
|
|
onValueChange={onChange}
|
|
/>
|
|
</ConsumerFieldShell>
|
|
)
|
|
}}
|
|
/>
|
|
)
|
|
}
|