// Select — выпадающий список вместо системного { setQuery(e.target.value); setActive(0); }} /> )}
{visible.length === 0 && (
Ничего не найдено
)} {visible.map((o, i) => { const isSel = String(o.value) === String(value); return (
setActive(i)} // mousedown, а не click: click сработал бы после того, как // обработчик закрытия по клику-мимо уже свернул список. onMouseDown={(e) => { e.preventDefault(); pick(o); }} > {o.label} {o.hint && {o.hint}} {isSel && }
); })}
)} ); } // Часовые пояса России и ближнего зарубежья — то, откуда приходит аудитория. // Список намеренно короткий: полный tzdata (600+ зон) в выпадашке // неюзабелен, а редкий случай закрывается поиском по подписи. const TIMEZONES = [ { value: 'Europe/Kaliningrad', label: 'Калининград', hint: 'UTC+2' }, { value: 'Europe/Moscow', label: 'Москва, Санкт-Петербург', hint: 'UTC+3' }, { value: 'Europe/Volgograd', label: 'Волгоград', hint: 'UTC+3' }, { value: 'Europe/Kirov', label: 'Киров', hint: 'UTC+3' }, { value: 'Europe/Astrakhan', label: 'Астрахань', hint: 'UTC+4' }, { value: 'Europe/Samara', label: 'Самара, Ижевск', hint: 'UTC+4' }, { value: 'Europe/Saratov', label: 'Саратов', hint: 'UTC+4' }, { value: 'Europe/Ulyanovsk', label: 'Ульяновск', hint: 'UTC+4' }, { value: 'Asia/Yekaterinburg', label: 'Екатеринбург, Пермь, Уфа', hint: 'UTC+5' }, { value: 'Asia/Omsk', label: 'Омск', hint: 'UTC+6' }, { value: 'Asia/Novosibirsk', label: 'Новосибирск', hint: 'UTC+7' }, { value: 'Asia/Barnaul', label: 'Барнаул', hint: 'UTC+7' }, { value: 'Asia/Tomsk', label: 'Томск', hint: 'UTC+7' }, { value: 'Asia/Novokuznetsk', label: 'Новокузнецк', hint: 'UTC+7' }, { value: 'Asia/Krasnoyarsk', label: 'Красноярск', hint: 'UTC+7' }, { value: 'Asia/Irkutsk', label: 'Иркутск, Улан-Удэ', hint: 'UTC+8' }, { value: 'Asia/Chita', label: 'Чита', hint: 'UTC+9' }, { value: 'Asia/Yakutsk', label: 'Якутск', hint: 'UTC+9' }, { value: 'Asia/Khandyga', label: 'Хандыга', hint: 'UTC+9' }, { value: 'Asia/Vladivostok', label: 'Владивосток, Хабаровск', hint: 'UTC+10' }, { value: 'Asia/Ust-Nera', label: 'Усть-Нера', hint: 'UTC+10' }, { value: 'Asia/Magadan', label: 'Магадан', hint: 'UTC+11' }, { value: 'Asia/Sakhalin', label: 'Южно-Сахалинск', hint: 'UTC+11' }, { value: 'Asia/Srednekolymsk', label: 'Среднеколымск', hint: 'UTC+11' }, { value: 'Asia/Kamchatka', label: 'Петропавловск-Камчатский', hint: 'UTC+12' }, { value: 'Asia/Anadyr', label: 'Анадырь', hint: 'UTC+12' }, { value: 'Europe/Minsk', label: 'Минск', hint: 'UTC+3' }, { value: 'Europe/Kyiv', label: 'Киев', hint: 'UTC+2' }, { value: 'Asia/Almaty', label: 'Алматы', hint: 'UTC+5' }, { value: 'Asia/Tashkent', label: 'Ташкент', hint: 'UTC+5' }, { value: 'Asia/Tbilisi', label: 'Тбилиси', hint: 'UTC+4' }, { value: 'Asia/Yerevan', label: 'Ереван', hint: 'UTC+4' }, { value: 'Asia/Baku', label: 'Баку', hint: 'UTC+4' }, { value: 'Asia/Bishkek', label: 'Бишкек', hint: 'UTC+6' }, { value: 'Asia/Dushanbe', label: 'Душанбе', hint: 'UTC+5' }, { value: 'Asia/Ashgabat', label: 'Ашхабад', hint: 'UTC+5' }, { value: 'Europe/Chisinau', label: 'Кишинёв', hint: 'UTC+2' }, { value: 'Europe/Riga', label: 'Рига', hint: 'UTC+2' }, { value: 'Europe/Vilnius', label: 'Вильнюс', hint: 'UTC+2' }, { value: 'Europe/Tallinn', label: 'Таллин', hint: 'UTC+2' }, { value: 'Europe/Berlin', label: 'Берлин', hint: 'UTC+1' }, { value: 'Europe/London', label: 'Лондон', hint: 'UTC+0' }, { value: 'Asia/Dubai', label: 'Дубай', hint: 'UTC+4' }, { value: 'Asia/Bangkok', label: 'Бангкок', hint: 'UTC+7' }, { value: 'America/New_York', label: 'Нью-Йорк', hint: 'UTC−5' }, { value: 'America/Los_Angeles', label: 'Лос-Анджелес', hint: 'UTC−8' }, { value: 'UTC', label: 'UTC', hint: 'UTC+0' }, ]; // Пол — необязательное поле: он управляет родом обращения в тексте. Пустая // строка и есть «не указывать», поэтому это полноценный пункт списка, а не // только плейсхолдер. // // «По имени» вместо прежнего «Не указывать»: пустое поле больше не означает // безродный текст. Сервер угадывает род по имени (llm.GuessGender) и уходит в // нейтральные формы только на неоднозначных именах — безродный русский на // десять тысяч слов модель не выдерживает, и разбор уезжал клиенту с чужим // родом. const GENDER_OPTIONS = [ { value: '', label: 'По имени' }, { value: 'female', label: 'Женский' }, { value: 'male', label: 'Мужской' }, ]; // TimezoneSelect — часовой пояс. Значение, которого нет в коротком списке // (пришло из геокодера), не теряется: подставляем его отдельным пунктом, // иначе выбор молча схлопнулся бы в плейсхолдер. function TimezoneSelect({ value, onChange, id, className, style, disabled }) { const options = React.useMemo(() => { if (!value || TIMEZONES.some(t => t.value === value)) return TIMEZONES; return [{ value, label: value, hint: 'определён по месту рождения' }, ...TIMEZONES]; }, [value]); return ( { onChange(e.target.value); fetchSugg(e.target.value); }} onFocus={() => sugg.length && setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 150)} /> {open && sugg.length > 0 && (
{sugg.map((s, i) => (
{ onPick(s); setOpen(false); setSugg([]); }} style={{ padding: '8px 10px', cursor: 'pointer', fontSize: 13, borderRadius: 6, color: 'var(--ink-secondary)' }} onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-base)'; }} onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}> {s.name}
))}
)} ); }; // DELIVERY_HOURS — во сколько присылать прогноз. // // Сутки целиком, а не «удобные часы»: кто-то читает перед сном, кто-то в ночную // смену. Подпись говорит, за какой день будет текст, потому что это не // очевидно: прогноз всегда про те сутки, в которые он приходит, и выбравший // одиннадцать вечера получит текст про уходящий день, а не про завтрашний. const DELIVERY_HOURS = Array.from({ length: 24 }, (_, h) => ({ value: String(h), label: String(h).padStart(2, '0') + ':00', hint: h < 5 ? 'ночью' : h < 12 ? 'утром' : h < 18 ? 'днём' : 'вечером', })); // browserTZ — пояс, который сообщает браузер. Нужен, чтобы не заставлять // человека искать свой город в списке: подставляем и даём поменять. function browserTZ() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (_) { return ''; } } // DeliveryPicker — когда присылать ежедневный прогноз. // // Пояс и час рядом намеренно: по отдельности они бессмысленны. «Девять утра» — // это девять утра где, а «Новосибирск» — это во сколько? function DeliveryPicker({ tz, hour, onTZ, onHour, disabled, note }) { return (
Когда присылать