Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
225 lines
7.5 KiB
TypeScript
225 lines
7.5 KiB
TypeScript
'use client'
|
|
|
|
import type { ReactNode } from 'react'
|
|
import type { Control } from 'react-hook-form'
|
|
|
|
import { useState } from 'react'
|
|
import { jalaaliMonthLength } from 'jalaali-js'
|
|
import Picker from 'react-mobile-picker'
|
|
import { Controller } from 'react-hook-form'
|
|
|
|
import Button from '@/components/formElements/Button'
|
|
import CalendarIcon from '@/components/icons/CalendarIcon'
|
|
import ConsumerModal from '@/components/consumer/ConsumerModal'
|
|
import { buildOutsideLabel } from '@/components/formElements/input/inputUtils'
|
|
import {
|
|
PERSIAN_MONTHS,
|
|
birthDateYears,
|
|
clampJalaliDateParts,
|
|
defaultBirthDateParts,
|
|
isoDateToJalaliParts,
|
|
jalaliPartsToIsoDate,
|
|
type JalaliDateParts,
|
|
} from '@/lib/birthDate'
|
|
import { cn } from '@/lib/cn'
|
|
import { texts } from '@/texts'
|
|
|
|
type Variant = 'flat' | 'bordered' | 'faded' | 'underlined'
|
|
|
|
interface Props {
|
|
className?: string
|
|
control: Control
|
|
description?: ReactNode
|
|
disabled?: boolean
|
|
label?: string
|
|
name: string
|
|
placeholder?: string
|
|
readonly?: boolean
|
|
required?: boolean
|
|
size?: 'sm' | 'md' | 'lg'
|
|
variant: Variant
|
|
}
|
|
|
|
const MONTHS = Array.from({ length: 12 }, (_, index) => String(index + 1))
|
|
const YEARS = birthDateYears()
|
|
const toPersianNumber = (value: number | string) => Number(value).toLocaleString('fa-IR', { useGrouping: false })
|
|
|
|
function WheelItem({ value, label }: { value: string; label: string }) {
|
|
return (
|
|
<Picker.Item
|
|
key={value}
|
|
className="flex items-center justify-center text-center"
|
|
value={value}
|
|
>
|
|
{({ selected }) => (
|
|
<span className={cn('transition-all duration-150', selected ? 'text-lg font-bold text-primary' : 'text-sm text-default-400')}>
|
|
{label}
|
|
</span>
|
|
)}
|
|
</Picker.Item>
|
|
)
|
|
}
|
|
|
|
function WheelFrame({ children }: { children: ReactNode }) {
|
|
return (
|
|
<div className="relative overflow-hidden rounded-2xl bg-default-50">
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-x-3 top-1/2 z-0 h-11 -translate-y-1/2 rounded-xl border-y border-primary/20 bg-primary/5"
|
|
/>
|
|
<div className="relative z-10">{children}</div>
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-x-0 top-0 z-20 h-16 bg-gradient-to-b from-default-50 to-transparent"
|
|
/>
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-16 bg-gradient-to-t from-default-50 to-transparent"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function BirthDateWheel({ value, onChange }: { value: JalaliDateParts; onChange: (value: JalaliDateParts) => void }) {
|
|
const days = Array.from({ length: jalaaliMonthLength(Number(value.year), Number(value.month)) }, (_, index) => String(index + 1))
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-2 grid grid-cols-3 text-center text-xs font-medium text-default-500">
|
|
<span>{texts.common.day}</span>
|
|
<span>{texts.common.month}</span>
|
|
<span>{texts.common.year}</span>
|
|
</div>
|
|
<WheelFrame>
|
|
<Picker
|
|
height={216}
|
|
itemHeight={44}
|
|
value={value}
|
|
wheelMode="natural"
|
|
onChange={(nextValue: JalaliDateParts) => {
|
|
onChange(clampJalaliDateParts(nextValue))
|
|
}}
|
|
>
|
|
<Picker.Column name="day">
|
|
{days.map((day) => (
|
|
<WheelItem
|
|
key={day}
|
|
label={toPersianNumber(day)}
|
|
value={day}
|
|
/>
|
|
))}
|
|
</Picker.Column>
|
|
<Picker.Column name="month">
|
|
{MONTHS.map((month) => (
|
|
<WheelItem
|
|
key={month}
|
|
label={PERSIAN_MONTHS[Number(month) - 1]}
|
|
value={month}
|
|
/>
|
|
))}
|
|
</Picker.Column>
|
|
<Picker.Column name="year">
|
|
{YEARS.map((year) => (
|
|
<WheelItem
|
|
key={year}
|
|
label={toPersianNumber(year)}
|
|
value={year}
|
|
/>
|
|
))}
|
|
</Picker.Column>
|
|
</Picker>
|
|
</WheelFrame>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function triggerClasses(size: Props['size'], variant: Variant, invalid: boolean) {
|
|
return cn(
|
|
'flex w-full items-center justify-between gap-3 px-3 text-right text-sm font-normal outline-none transition',
|
|
size === 'sm' ? 'h-9' : size === 'lg' ? 'h-12' : 'h-10',
|
|
variant === 'underlined' ? 'rounded-none border-b bg-transparent' : 'rounded-xl',
|
|
variant === 'bordered' && 'border bg-transparent',
|
|
variant === 'faded' && 'border bg-default-100',
|
|
variant === 'flat' && 'bg-default-100',
|
|
invalid ? 'border-fourth-900 text-fourth-900' : 'border-default-200 text-text-main',
|
|
'disabled:cursor-not-allowed disabled:opacity-50'
|
|
)
|
|
}
|
|
|
|
function formatSelectedLabel(value: JalaliDateParts) {
|
|
return `${toPersianNumber(value.day)} ${PERSIAN_MONTHS[Number(value.month) - 1]} ${toPersianNumber(value.year)}`
|
|
}
|
|
|
|
export default function JalaliBirthDateFieldControl(props: Props) {
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [draft, setDraft] = useState<JalaliDateParts>(defaultBirthDateParts())
|
|
|
|
return (
|
|
<Controller
|
|
control={props.control}
|
|
name={props.name}
|
|
render={({ field, fieldState: { error } }) => {
|
|
const selected = isoDateToJalaliParts(field.value)
|
|
const displayValue = selected ? formatSelectedLabel(selected) : ''
|
|
|
|
const openPicker = () => {
|
|
if (props.disabled || props.readonly) return
|
|
setDraft(selected ?? defaultBirthDateParts())
|
|
setIsOpen(true)
|
|
}
|
|
|
|
const accept = () => {
|
|
field.onChange(jalaliPartsToIsoDate(draft))
|
|
field.onBlur()
|
|
setIsOpen(false)
|
|
}
|
|
|
|
return (
|
|
<div className={cn('relative flex flex-col gap-1', props.className)}>
|
|
<div className={cn('labelClass flex items-center', error && '!text-fourth-900')}>
|
|
{buildOutsideLabel(props.label, <CalendarIcon className="size-4" />, props.required, 0)}
|
|
</div>
|
|
<Button
|
|
fullWidth
|
|
aria-label={props.label ?? texts.common.selectBirthDate}
|
|
className={triggerClasses(props.size, props.variant, Boolean(error))}
|
|
disabled={props.disabled}
|
|
variant="bordered"
|
|
onClick={openPicker}
|
|
>
|
|
<span className="flex w-full items-center justify-between gap-2">
|
|
<span className={displayValue ? undefined : 'text-default-400'}>{displayValue || props.placeholder}</span>
|
|
<span className="text-default-400">
|
|
<CalendarIcon className="size-4" />
|
|
</span>
|
|
</span>
|
|
</Button>
|
|
{error ? <p className="text-tiny text-fourth-900">{error.message}</p> : null}
|
|
{props.description ? <p className="text-tiny text-default-500">{props.description}</p> : null}
|
|
|
|
<ConsumerModal
|
|
acceptBtnText={texts.common.confirm}
|
|
bodyClassName="flex-none overflow-hidden"
|
|
className="h-auto px-4"
|
|
isOpen={isOpen}
|
|
rejectBtnText={texts.common.cancel}
|
|
size="md"
|
|
title={props.label ?? texts.common.birthDate}
|
|
onAccept={accept}
|
|
onOpenChange={setIsOpen}
|
|
onReject={() => {
|
|
setIsOpen(false)
|
|
}}
|
|
>
|
|
<BirthDateWheel
|
|
value={draft}
|
|
onChange={setDraft}
|
|
/>
|
|
</ConsumerModal>
|
|
</div>
|
|
)
|
|
}}
|
|
/>
|
|
)
|
|
}
|