feat(ui): тёмный DateField на react-day-picker вместо нативного input

Нативный <input type="date"> выглядел чужеродно (браузерный светлый календарь).
Новый общий компонент DateField: react-day-picker внутри Radix Popover, тёмная
тема через CSS-переменные .rdp-dark, навигация — наши иконки из барреля,
локализация месяцев/дней по языку кабинета (date-fns). Принимает/отдаёт
'YYYY-MM-DD' — drop-in замена нативного инпута. Подключён в селекторе периода
статистики (свой период). Остальные места — следующими шагами.
This commit is contained in:
c0mrade
2026-06-02 16:10:48 +03:00
parent 9a05f23a04
commit edd2d25024
9 changed files with 205 additions and 8 deletions

43
package-lock.json generated
View File

@@ -53,6 +53,7 @@
"jsencrypt": "^3.5.4",
"qrcode.react": "^4.2.0",
"react": "^19.2.4",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.4",
"react-icons": "^5.6.0",
@@ -392,6 +393,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@date-fns/tz": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -4564,6 +4571,16 @@
"node": ">=12"
}
},
"node_modules/date-fns": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -6870,6 +6887,32 @@
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz",
"integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"@types/react": ">=16.8.0",
"react": ">=16.8.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",

View File

@@ -61,6 +61,7 @@
"jsencrypt": "^3.5.4",
"qrcode.react": "^4.2.0",
"react": "^19.2.4",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.4",
"react-icons": "^5.6.0",

View File

@@ -0,0 +1,106 @@
import * as Popover from '@radix-ui/react-popover';
import { enUS, faIR, ru, zhCN } from 'date-fns/locale';
import { useState } from 'react';
import { DayPicker, type Matcher } from 'react-day-picker';
import { useTranslation } from 'react-i18next';
import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from './icons';
import 'react-day-picker/style.css';
interface DateFieldProps {
/** Selected date as 'YYYY-MM-DD', or '' when empty. */
value: string;
onChange: (value: string) => void;
placeholder?: string;
/** Inclusive bounds as 'YYYY-MM-DD'. */
min?: string;
max?: string;
/** Override the trigger button classes. */
className?: string;
}
const LOCALES = { ru, en: enUS, zh: zhCN, fa: faIR } as const;
const pad = (n: number) => String(n).padStart(2, '0');
const toISO = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const parseISO = (s: string): Date | undefined => {
if (!s) return undefined;
const [y, m, d] = s.split('-').map(Number);
if (!y || !m || !d) return undefined;
return new Date(y, m - 1, d);
};
function NavChevron({ orientation }: { orientation?: 'left' | 'right' | 'up' | 'down' }) {
return orientation === 'right' ? (
<ChevronRightIcon className="h-4 w-4" />
) : (
<ChevronLeftIcon className="h-4 w-4" />
);
}
/**
* Dark-themed date field — Radix Popover trigger + react-day-picker calendar.
* Drop-in replacement for `<input type="date">`: takes/returns 'YYYY-MM-DD'.
* Month/weekday names follow the UI language (date-fns locale), navigation uses
* the shared barrel chevrons, theming via the .rdp-dark variable overrides.
*/
export function DateField({ value, onChange, placeholder, min, max, className }: DateFieldProps) {
const { i18n } = useTranslation();
const [open, setOpen] = useState(false);
const selected = parseISO(value);
const locale = LOCALES[i18n.language as keyof typeof LOCALES] ?? enUS;
const display = selected
? selected.toLocaleDateString(i18n.language, {
day: '2-digit',
month: 'short',
year: 'numeric',
})
: '';
const disabled: Matcher[] = [];
const minDate = parseISO(min ?? '');
const maxDate = parseISO(max ?? '');
if (minDate) disabled.push({ before: minDate });
if (maxDate) disabled.push({ after: maxDate });
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type="button"
className={
className ??
'flex items-center gap-2 rounded-lg border border-dark-600 bg-dark-800 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:border-dark-500'
}
>
<CalendarIcon className="h-4 w-4 shrink-0 text-dark-500" />
<span className={selected ? '' : 'text-dark-500'}>{display || placeholder}</span>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={6}
className="rdp-dark z-50 rounded-xl border border-dark-700 bg-dark-800 p-2 shadow-xl"
>
<DayPicker
mode="single"
locale={locale}
selected={selected}
defaultMonth={selected}
disabled={disabled}
components={{ Chevron: NavChevron }}
onSelect={(d) => {
if (d) {
onChange(toISO(d));
setOpen(false);
}
}}
/>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { SALES_STATS } from '../../constants/salesStats';
import { getMonthToDateRange, isMonthToDate } from '../../utils/period';
import { DateField } from '../DateField';
interface PeriodSelectorProps {
value: { days?: number; startDate?: string; endDate?: string };
@@ -68,18 +69,18 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
{showCustom && (
<div className="flex items-center gap-2">
<input
type="date"
<DateField
value={value.startDate || ''}
onChange={(e) => handleDateChange('startDate', e.target.value)}
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
onChange={(v) => handleDateChange('startDate', v)}
max={value.endDate}
placeholder={t('admin.salesStats.period.from')}
/>
<span className="text-dark-500">{'—'}</span>
<input
type="date"
<DateField
value={value.endDate || ''}
onChange={(e) => handleDateChange('endDate', e.target.value)}
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
onChange={(v) => handleDateChange('endDate', v)}
min={value.startDate}
placeholder={t('admin.salesStats.period.to')}
/>
</div>
)}

View File

@@ -1283,6 +1283,8 @@
"subtitle": "Revenue, trials, subscriptions, and renewals",
"period": {
"thisMonth": "This month",
"from": "From",
"to": "To",
"week": "7 days",
"month": "30 days",
"quarter": "90 days",

View File

@@ -1156,6 +1156,8 @@
"subtitle": "درآمد، آزمایشی، اشتراک و تمدید",
"period": {
"thisMonth": "این ماه",
"from": "از",
"to": "تا",
"week": "۷ روز",
"month": "۳۰ روز",
"quarter": "۹۰ روز",

View File

@@ -1305,6 +1305,8 @@
"subtitle": "Доход, триалы, подписки и продления",
"period": {
"thisMonth": "Этот месяц",
"from": "Начало",
"to": "Конец",
"week": "7 дней",
"month": "30 дней",
"quarter": "90 дней",

View File

@@ -1156,6 +1156,8 @@
"subtitle": "收入、试用、订阅和续订",
"period": {
"thisMonth": "本月",
"from": "从",
"to": "到",
"week": "7天",
"month": "30天",
"quarter": "90天",

View File

@@ -1392,6 +1392,44 @@ input[type='checkbox']:hover:not(:checked) {
outline: none;
}
/* react-day-picker (DateField) — dark theme via the library's CSS variables. */
.rdp-dark {
--rdp-accent-color: rgb(var(--color-accent-500));
--rdp-accent-background-color: rgb(var(--color-accent-500) / 0.18);
--rdp-today-color: rgb(var(--color-accent-400));
--rdp-day-height: 2.25rem;
--rdp-day-width: 2.25rem;
--rdp-outside-opacity: 0.35;
--rdp-disabled-opacity: 0.3;
--rdp-selected-border: none;
color: rgb(var(--color-dark-200));
font-size: 0.85rem;
}
.rdp-dark .rdp-month_caption,
.rdp-dark .rdp-caption_label {
color: rgb(var(--color-dark-100));
font-weight: 600;
text-transform: capitalize;
}
.rdp-dark .rdp-weekday {
color: rgb(var(--color-dark-500));
font-weight: 500;
text-transform: capitalize;
}
.rdp-dark .rdp-day_button {
border-radius: 0.5rem;
}
.rdp-dark .rdp-day_button:hover:not([disabled]) {
background-color: rgb(var(--color-dark-700));
}
.rdp-dark .rdp-selected .rdp-day_button {
color: #fff;
font-weight: 600;
}
.rdp-dark .rdp-chevron {
fill: currentColor;
}
.light input[type='checkbox'] {
border-color: rgb(var(--color-champagne-400));
background-color: rgb(var(--color-champagne-100));