mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
43
package-lock.json
generated
43
package-lock.json
generated
@@ -53,6 +53,7 @@
|
|||||||
"jsencrypt": "^3.5.4",
|
"jsencrypt": "^3.5.4",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^16.5.4",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
@@ -392,6 +393,12 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
@@ -4564,6 +4571,16 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -6870,6 +6887,32 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
"jsencrypt": "^3.5.4",
|
"jsencrypt": "^3.5.4",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^16.5.4",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
|
|||||||
106
src/components/DateField.tsx
Normal file
106
src/components/DateField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { SALES_STATS } from '../../constants/salesStats';
|
import { SALES_STATS } from '../../constants/salesStats';
|
||||||
import { getMonthToDateRange, isMonthToDate } from '../../utils/period';
|
import { getMonthToDateRange, isMonthToDate } from '../../utils/period';
|
||||||
|
import { DateField } from '../DateField';
|
||||||
|
|
||||||
interface PeriodSelectorProps {
|
interface PeriodSelectorProps {
|
||||||
value: { days?: number; startDate?: string; endDate?: string };
|
value: { days?: number; startDate?: string; endDate?: string };
|
||||||
@@ -68,18 +69,18 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
|||||||
|
|
||||||
{showCustom && (
|
{showCustom && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<DateField
|
||||||
type="date"
|
|
||||||
value={value.startDate || ''}
|
value={value.startDate || ''}
|
||||||
onChange={(e) => handleDateChange('startDate', e.target.value)}
|
onChange={(v) => handleDateChange('startDate', v)}
|
||||||
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
|
max={value.endDate}
|
||||||
|
placeholder={t('admin.salesStats.period.from')}
|
||||||
/>
|
/>
|
||||||
<span className="text-dark-500">{'—'}</span>
|
<span className="text-dark-500">{'—'}</span>
|
||||||
<input
|
<DateField
|
||||||
type="date"
|
|
||||||
value={value.endDate || ''}
|
value={value.endDate || ''}
|
||||||
onChange={(e) => handleDateChange('endDate', e.target.value)}
|
onChange={(v) => handleDateChange('endDate', v)}
|
||||||
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
|
min={value.startDate}
|
||||||
|
placeholder={t('admin.salesStats.period.to')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1283,6 +1283,8 @@
|
|||||||
"subtitle": "Revenue, trials, subscriptions, and renewals",
|
"subtitle": "Revenue, trials, subscriptions, and renewals",
|
||||||
"period": {
|
"period": {
|
||||||
"thisMonth": "This month",
|
"thisMonth": "This month",
|
||||||
|
"from": "From",
|
||||||
|
"to": "To",
|
||||||
"week": "7 days",
|
"week": "7 days",
|
||||||
"month": "30 days",
|
"month": "30 days",
|
||||||
"quarter": "90 days",
|
"quarter": "90 days",
|
||||||
|
|||||||
@@ -1156,6 +1156,8 @@
|
|||||||
"subtitle": "درآمد، آزمایشی، اشتراک و تمدید",
|
"subtitle": "درآمد، آزمایشی، اشتراک و تمدید",
|
||||||
"period": {
|
"period": {
|
||||||
"thisMonth": "این ماه",
|
"thisMonth": "این ماه",
|
||||||
|
"from": "از",
|
||||||
|
"to": "تا",
|
||||||
"week": "۷ روز",
|
"week": "۷ روز",
|
||||||
"month": "۳۰ روز",
|
"month": "۳۰ روز",
|
||||||
"quarter": "۹۰ روز",
|
"quarter": "۹۰ روز",
|
||||||
|
|||||||
@@ -1305,6 +1305,8 @@
|
|||||||
"subtitle": "Доход, триалы, подписки и продления",
|
"subtitle": "Доход, триалы, подписки и продления",
|
||||||
"period": {
|
"period": {
|
||||||
"thisMonth": "Этот месяц",
|
"thisMonth": "Этот месяц",
|
||||||
|
"from": "Начало",
|
||||||
|
"to": "Конец",
|
||||||
"week": "7 дней",
|
"week": "7 дней",
|
||||||
"month": "30 дней",
|
"month": "30 дней",
|
||||||
"quarter": "90 дней",
|
"quarter": "90 дней",
|
||||||
|
|||||||
@@ -1156,6 +1156,8 @@
|
|||||||
"subtitle": "收入、试用、订阅和续订",
|
"subtitle": "收入、试用、订阅和续订",
|
||||||
"period": {
|
"period": {
|
||||||
"thisMonth": "本月",
|
"thisMonth": "本月",
|
||||||
|
"from": "从",
|
||||||
|
"to": "到",
|
||||||
"week": "7天",
|
"week": "7天",
|
||||||
"month": "30天",
|
"month": "30天",
|
||||||
"quarter": "90天",
|
"quarter": "90天",
|
||||||
|
|||||||
@@ -1392,6 +1392,44 @@ input[type='checkbox']:hover:not(:checked) {
|
|||||||
outline: none;
|
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'] {
|
.light input[type='checkbox'] {
|
||||||
border-color: rgb(var(--color-champagne-400));
|
border-color: rgb(var(--color-champagne-400));
|
||||||
background-color: rgb(var(--color-champagne-100));
|
background-color: rgb(var(--color-champagne-100));
|
||||||
|
|||||||
Reference in New Issue
Block a user