From edd2d25024989a0b2d7888b82fa2f13dce60b63e Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 16:10:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=D1=82=D1=91=D0=BC=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20DateField=20=D0=BD=D0=B0=20react-day-picker=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=BD=D0=B0=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Нативный выглядел чужеродно (браузерный светлый календарь). Новый общий компонент DateField: react-day-picker внутри Radix Popover, тёмная тема через CSS-переменные .rdp-dark, навигация — наши иконки из барреля, локализация месяцев/дней по языку кабинета (date-fns). Принимает/отдаёт 'YYYY-MM-DD' — drop-in замена нативного инпута. Подключён в селекторе периода статистики (свой период). Остальные места — следующими шагами. --- package-lock.json | 43 +++++++ package.json | 1 + src/components/DateField.tsx | 106 ++++++++++++++++++ src/components/sales-stats/PeriodSelector.tsx | 17 +-- src/locales/en.json | 2 + src/locales/fa.json | 2 + src/locales/ru.json | 2 + src/locales/zh.json | 2 + src/styles/globals.css | 38 +++++++ 9 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 src/components/DateField.tsx diff --git a/package-lock.json b/package-lock.json index 867b7de..111c915 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c3c0f2a..f9298a5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/DateField.tsx b/src/components/DateField.tsx new file mode 100644 index 0000000..d634c2d --- /dev/null +++ b/src/components/DateField.tsx @@ -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' ? ( + + ) : ( + + ); +} + +/** + * Dark-themed date field — Radix Popover trigger + react-day-picker calendar. + * Drop-in replacement for ``: 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 ( + + + + + + + { + if (d) { + onChange(toISO(d)); + setOpen(false); + } + }} + /> + + + + ); +} diff --git a/src/components/sales-stats/PeriodSelector.tsx b/src/components/sales-stats/PeriodSelector.tsx index c55874a..19c465b 100644 --- a/src/components/sales-stats/PeriodSelector.tsx +++ b/src/components/sales-stats/PeriodSelector.tsx @@ -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 && (
- 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')} /> {'—'} - 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')} />
)} diff --git a/src/locales/en.json b/src/locales/en.json index c14df7d..d229697 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fa.json b/src/locales/fa.json index f84cde8..7006b10 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1156,6 +1156,8 @@ "subtitle": "درآمد، آزمایشی، اشتراک و تمدید", "period": { "thisMonth": "این ماه", + "from": "از", + "to": "تا", "week": "۷ روز", "month": "۳۰ روز", "quarter": "۹۰ روز", diff --git a/src/locales/ru.json b/src/locales/ru.json index 8c18e60..b0db7dd 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1305,6 +1305,8 @@ "subtitle": "Доход, триалы, подписки и продления", "period": { "thisMonth": "Этот месяц", + "from": "Начало", + "to": "Конец", "week": "7 дней", "month": "30 дней", "quarter": "90 дней", diff --git a/src/locales/zh.json b/src/locales/zh.json index 68c6184..7778363 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1156,6 +1156,8 @@ "subtitle": "收入、试用、订阅和续订", "period": { "thisMonth": "本月", + "from": "从", + "to": "到", "week": "7天", "month": "30天", "quarter": "90天", diff --git a/src/styles/globals.css b/src/styles/globals.css index 1ed7da8..2561198 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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));