креативы

This commit is contained in:
Artem Tsyrulnikov
2026-01-03 16:11:05 +03:00
parent 841a8b5aaa
commit c04faf8132
15 changed files with 597 additions and 59 deletions

View File

@@ -2,6 +2,7 @@ package screens
import (
"fmt"
"regexp"
"strings"
"time"
@@ -10,12 +11,11 @@ import (
)
const (
pickerViewCalendar = "calendar"
pickerViewMonths = "months"
pickerViewTimeHour = "time_hour"
pickerViewTimeMin = "time_minute"
pickerViewConfirm = "confirm"
dtpCallbackPrefix = "dtp:"
pickerViewCalendar = "calendar"
pickerViewMonths = "months"
pickerViewTimeCombined = "time_combined"
pickerViewConfirm = "confirm"
dtpCallbackPrefix = "dtp:"
)
var mskLocation = time.FixedZone("MSK", 3*60*60)
@@ -72,7 +72,9 @@ func NewDateTimePicker(cfg DateTimePickerConfig) *DateTimePicker {
}
func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) {
text := fmt.Sprintf("<i>… %s</i>\n\n<b>%s</b>\n\n", s.Title, s.Title)
text := fmt.Sprintf("<i>… %s</i>\n\n", s.Title)
text += fmt.Sprintf("<b>%s</b>\n", s.formatSelectedDateTime())
text += "\n"
switch s.view {
case pickerViewCalendar:
text += s.renderCalendarTitle()
@@ -80,14 +82,8 @@ func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) {
case pickerViewMonths:
text += "Выберите месяц\n\n"
b.Render(text, s.monthsKeyboard(), mode)
case pickerViewTimeHour:
text += fmt.Sprintf("Выбрано: <b>%s</b>\n\n", s.formatTimePreview(false))
text += "Выберите час\n\n"
b.Render(text, s.timeHourKeyboard(), mode)
case pickerViewTimeMin:
text += fmt.Sprintf("Выбрано: <b>%s</b>\n\n", s.formatTimePreview(false))
text += "Выберите минуты\n\n"
b.Render(text, s.timeMinuteKeyboard(), mode)
case pickerViewTimeCombined:
b.Render(text, s.timeCombinedKeyboard(), mode)
case pickerViewConfirm:
text += s.renderSelectedDateTime()
b.Render(text, s.confirmKeyboard(), mode)
@@ -152,13 +148,11 @@ func (s *DateTimePicker) handleBack(b *bot.Bot) {
switch s.view {
case pickerViewMonths:
s.view = pickerViewCalendar
case pickerViewTimeHour:
case pickerViewTimeCombined:
s.view = pickerViewCalendar
case pickerViewTimeMin:
s.view = pickerViewTimeHour
case pickerViewConfirm:
if s.IncludeTime {
s.view = pickerViewTimeMin
s.view = pickerViewTimeCombined
} else {
s.view = pickerViewCalendar
}
@@ -172,23 +166,17 @@ func (s *DateTimePicker) handleBack(b *bot.Bot) {
}
func (s *DateTimePicker) renderCalendarTitle() string {
header := monthName(s.month)
if s.selectedDate != nil {
header = fmt.Sprintf("%s\nВыбрано: %s", header, s.selectedDate.In(mskLocation).Format("02.01.2006"))
if s.year == time.Now().In(mskLocation).Year() {
return "\n"
}
return header + "\n\n"
return fmt.Sprintf("%d\n\n", s.year)
}
func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, bot.Row(
bot.Button("«", dtpCallbackPrefix+"year_prev"),
bot.Button(fmt.Sprintf("%d", s.year), "empty"),
bot.Button("»", dtpCallbackPrefix+"year_next"),
))
rows = append(rows, bot.Row(
bot.Button("←", dtpCallbackPrefix+"month_prev"),
s.monthPrevButton(),
bot.Button(monthName(s.month), dtpCallbackPrefix+"open_months"),
bot.Button("→", dtpCallbackPrefix+"month_next"),
))
@@ -248,11 +236,6 @@ func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup {
func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, bot.Row(
bot.Button("«", dtpCallbackPrefix+"year_prev"),
bot.Button(fmt.Sprintf("%d", s.year), "empty"),
bot.Button("»", dtpCallbackPrefix+"year_next"),
))
months := []string{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"}
for i := 0; i < 12; i += 3 {
@@ -270,32 +253,35 @@ func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup {
return bot.Keyboard(rows...)
}
func (s *DateTimePicker) timeHourKeyboard() echotron.InlineKeyboardMarkup {
func (s *DateTimePicker) timeCombinedKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
for i := 0; i < 24; i += 4 {
rows = append(rows, bot.Row(bot.Button("Часы", "empty")))
for i := 0; i < 24; i += 6 {
rows = append(rows, bot.Row(
s.hourButton(i),
s.hourButton(i+1),
s.hourButton(i+2),
s.hourButton(i+3),
s.hourButton(i+4),
s.hourButton(i+5),
))
}
rows = append(rows, bot.Row(bot.Button("← Назад", "back")))
return bot.Keyboard(rows...)
}
func (s *DateTimePicker) timeMinuteKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, bot.Row(bot.Button("Минуты", "empty")))
minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}
for i := 0; i < len(minutes); i += 4 {
for i := 0; i < len(minutes); i += 6 {
rows = append(rows, bot.Row(
s.minuteButton(minutes[i]),
s.minuteButton(minutes[i+1]),
s.minuteButton(minutes[i+2]),
s.minuteButton(minutes[i+3]),
s.minuteButton(minutes[i+4]),
s.minuteButton(minutes[i+5]),
))
}
rows = append(rows, bot.Row(bot.Button("← Назад", "back")))
rows = append(rows, bot.Row(
bot.Button("← Назад", "back"),
bot.Button("Готово", dtpCallbackPrefix+"confirm"),
))
return bot.Keyboard(rows...)
}
@@ -310,7 +296,7 @@ func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup {
func (s *DateTimePicker) renderSelectedDateTime() string {
if s.selectedDate == nil {
return "Дата не выбрана"
return ""
}
hour := 0
minute := 0
@@ -321,7 +307,7 @@ func (s *DateTimePicker) renderSelectedDateTime() string {
minute = *s.selectedMinute
}
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, mskLocation)
return fmt.Sprintf("Выбрано: <b>%s</b>\n\nНажмите «Готово»", value.Format("02.01.2006 15:04"))
return fmt.Sprintf("<b>%s</b>", formatCompactDateTime(value))
}
func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string {
@@ -340,6 +326,41 @@ func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string {
return fmt.Sprintf("%s:%s", hour, minute)
}
func (s *DateTimePicker) formatSelectedDateTime() string {
displayDate, ok := s.displayDate()
if !ok {
return "—"
}
timeLabel := "--:--"
if s.selectedHour != nil && s.selectedMinute != nil {
timeLabel = fmt.Sprintf("%02d:%02d", *s.selectedHour, *s.selectedMinute)
}
weekday := weekdayName(displayDate.Weekday())
month := monthShort(displayDate.Month())
return fmt.Sprintf("%s %02d %s %s %dг.", weekday, displayDate.Day(), month, timeLabel, displayDate.Year())
}
func (s *DateTimePicker) displayDate() (time.Time, bool) {
if s.selectedDate == nil {
if s.view == pickerViewCalendar || s.view == pickerViewMonths {
return time.Date(s.year, s.month, 1, 0, 0, 0, 0, mskLocation), true
}
return time.Time{}, false
}
if (s.view == pickerViewCalendar || s.view == pickerViewMonths) &&
(s.selectedDate.Year() != s.year || s.selectedDate.Month() != s.month) {
day := s.selectedDate.Day()
maxDay := daysInMonth(s.year, s.month)
if day > maxDay {
day = maxDay
}
return time.Date(s.year, s.month, day, 0, 0, 0, 0, mskLocation), true
}
return s.selectedDate.In(mskLocation), true
}
func (s *DateTimePicker) handleDaySelect(payload string) {
datePart := strings.TrimPrefix(payload, "day:")
parsed, err := time.ParseInLocation("2006-01-02", datePart, mskLocation)
@@ -348,7 +369,7 @@ func (s *DateTimePicker) handleDaySelect(payload string) {
}
s.selectedDate = &parsed
if s.IncludeTime {
s.view = pickerViewTimeHour
s.view = pickerViewTimeCombined
s.selectedHour = nil
s.selectedMinute = nil
} else {
@@ -365,7 +386,6 @@ func (s *DateTimePicker) handleHourSelect(payload string) {
return
}
s.selectedHour = &hour
s.view = pickerViewTimeMin
}
func (s *DateTimePicker) handleMinuteSelect(payload string) {
@@ -377,13 +397,15 @@ func (s *DateTimePicker) handleMinuteSelect(payload string) {
return
}
s.selectedMinute = &minute
s.view = pickerViewConfirm
}
func (s *DateTimePicker) confirmSelection(b *bot.Bot) {
if s.selectedDate == nil {
return
}
if s.IncludeTime && (s.selectedHour == nil || s.selectedMinute == nil) {
return
}
hour := 0
minute := 0
if s.selectedHour != nil {
@@ -453,6 +475,10 @@ func (s *DateTimePicker) isMinuteDisabled(minute int) bool {
}
func (s *DateTimePicker) prevMonth() {
now := time.Now().In(mskLocation)
if s.year == now.Year() && s.month == now.Month() {
return
}
if s.month == time.January {
s.month = time.December
s.year--
@@ -470,6 +496,14 @@ func (s *DateTimePicker) nextMonth() {
}
}
func (s *DateTimePicker) monthPrevButton() echotron.InlineKeyboardButton {
now := time.Now().In(mskLocation)
if s.year == now.Year() && s.month == now.Month() {
return bot.Button("-", "empty")
}
return bot.Button("←", dtpCallbackPrefix+"month_prev")
}
func daysInMonth(year int, month time.Month) int {
return time.Date(year, month+1, 0, 0, 0, 0, 0, mskLocation).Day()
}
@@ -496,6 +530,285 @@ func parseInt(value string) int {
return result
}
func (s *DateTimePicker) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil || strings.TrimSpace(u.Message.Text) == "" {
return
}
input := strings.TrimSpace(u.Message.Text)
switch s.view {
case pickerViewCalendar:
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute)
if !hasDate {
s.Enter(b, bot.NewMessage)
return
}
s.selectedDate = &value
if hasTime {
hour := value.Hour()
minute := value.Minute()
s.selectedHour = &hour
s.selectedMinute = &minute
}
if s.IncludeTime {
s.view = pickerViewTimeCombined
} else {
s.view = pickerViewConfirm
}
s.Enter(b, bot.NewMessage)
case pickerViewTimeCombined:
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute)
if !hasTime {
s.Enter(b, bot.NewMessage)
return
}
if hasDate {
s.selectedDate = &value
}
hour := value.Hour()
minute := value.Minute()
s.selectedHour = &hour
s.selectedMinute = &minute
s.Enter(b, bot.NewMessage)
default:
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute)
if !hasDate && !hasTime {
s.Enter(b, bot.NewMessage)
return
}
s.selectedDate = &value
hour := value.Hour()
minute := value.Minute()
s.selectedHour = &hour
s.selectedMinute = &minute
s.view = pickerViewConfirm
s.Enter(b, bot.NewMessage)
}
}
func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func parseDateTimeInput(
input string,
currentDate *time.Time,
currentHour *int,
currentMinute *int,
) (time.Time, bool, bool) {
text := strings.ToLower(strings.TrimSpace(input))
text = strings.ReplaceAll(text, ",", " ")
text = strings.ReplaceAll(text, " ", " ")
now := time.Now().In(mskLocation)
baseDate := now
if currentDate != nil {
baseDate = currentDate.In(mskLocation)
}
date, dateOk := parseDate(text, baseDate)
hour, minute, timeOk := parseTime(text, currentHour, currentMinute)
if !dateOk && !timeOk {
return time.Time{}, false, false
}
if !dateOk {
date = time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), 0, 0, 0, 0, mskLocation)
}
if !timeOk {
hour = 0
minute = 0
}
value := time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, mskLocation)
if value.Before(now) {
return time.Time{}, false, false
}
return value, dateOk, timeOk
}
func parseDate(text string, baseDate time.Time) (time.Time, bool) {
if strings.Contains(text, "сегодня") {
return baseDate, true
}
if strings.Contains(text, "завтра") {
return baseDate.AddDate(0, 0, 1), true
}
if strings.Contains(text, "послезавтра") {
return baseDate.AddDate(0, 0, 2), true
}
if date := parseNumericDate(text); !date.IsZero() {
return date, true
}
if date := parseWordDate(text); !date.IsZero() {
return date, true
}
return time.Time{}, false
}
func parseNumericDate(text string) time.Time {
reYMD := regexp.MustCompile(`\b(\d{4})[./-](\d{1,2})[./-](\d{1,2})\b`)
if match := reYMD.FindStringSubmatch(text); len(match) == 4 {
year := parseInt(match[1])
month := parseInt(match[2])
day := parseInt(match[3])
return safeDate(year, month, day)
}
reDMY := regexp.MustCompile(`\b(\d{1,2})[./-](\d{1,2})(?:[./-](\d{2,4}))?\b`)
if match := reDMY.FindStringSubmatch(text); len(match) >= 3 {
day := parseInt(match[1])
month := parseInt(match[2])
year := time.Now().In(mskLocation).Year()
if len(match) == 4 && match[3] != "" {
year = parseInt(match[3])
if year < 100 {
year += 2000
}
}
return safeDate(year, month, day)
}
return time.Time{}
}
func parseWordDate(text string) time.Time {
monthMap := map[string]time.Month{
"янв": 1, "январ": 1, "января": 1,
"фев": 2, "феврал": 2, "февраля": 2,
"мар": 3, "марта": 3,
"апр": 4, "апрел": 4, "апреля": 4,
"май": 5, "мая": 5,
"июн": 6, "июня": 6,
"июл": 7, "июля": 7,
"авг": 8, "август": 8, "августа": 8,
"сен": 9, "сент": 9, "сентябр": 9, "сентября": 9,
"окт": 10, "октябр": 10, "октября": 10,
"ноя": 11, "ноябр": 11, "ноября": 11,
"дек": 12, "декабр": 12, "декабря": 12,
}
re := regexp.MustCompile(`\b(\d{1,2})\s+([a-zа-яё]+)(?:\s+(\d{2,4}))?\b`)
match := re.FindStringSubmatch(text)
if len(match) == 0 {
return time.Time{}
}
day := parseInt(match[1])
monthToken := match[2]
var month time.Month
for key, value := range monthMap {
if strings.HasPrefix(monthToken, key) {
month = value
break
}
}
if month == 0 {
return time.Time{}
}
year := time.Now().In(mskLocation).Year()
if len(match) == 4 && match[3] != "" {
year = parseInt(match[3])
if year < 100 {
year += 2000
}
}
return safeDate(year, int(month), day)
}
func parseTime(text string, currentHour *int, currentMinute *int) (int, int, bool) {
re := regexp.MustCompile(`\b(\d{1,2})[:.](\d{2})\b`)
if match := re.FindStringSubmatch(text); len(match) == 3 {
hour := parseInt(match[1])
minute := parseInt(match[2])
if hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 {
return hour, minute, true
}
}
reCompact := regexp.MustCompile(`\b(\d{1,2})\s+(\d{2})\b`)
if match := reCompact.FindStringSubmatch(text); len(match) == 3 {
hour := parseInt(match[1])
minute := parseInt(match[2])
if hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 {
return hour, minute, true
}
}
reHour := regexp.MustCompile(`\b(\d{1,2})\s*(?:ч|час|часов)\b`)
if match := reHour.FindStringSubmatch(text); len(match) == 2 {
hour := parseInt(match[1])
if hour >= 0 && hour <= 23 {
minute := 0
if currentMinute != nil {
minute = *currentMinute
}
return hour, minute, true
}
}
reJustHour := regexp.MustCompile(`\b(\d{1,2})\b`)
if match := reJustHour.FindStringSubmatch(text); len(match) == 2 && strings.Contains(text, "в") {
hour := parseInt(match[1])
if hour >= 0 && hour <= 23 {
minute := 0
if currentMinute != nil {
minute = *currentMinute
}
return hour, minute, true
}
}
if currentHour != nil && currentMinute != nil {
return *currentHour, *currentMinute, true
}
return 0, 0, false
}
func safeDate(year, month, day int) time.Time {
if month < 1 || month > 12 {
return time.Time{}
}
if day < 1 || day > 31 {
return time.Time{}
}
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, mskLocation)
}
func weekdayName(day time.Weekday) string {
switch day {
case time.Monday:
return "Пн"
case time.Tuesday:
return "Вт"
case time.Wednesday:
return "Ср"
case time.Thursday:
return "Чт"
case time.Friday:
return "Пт"
case time.Saturday:
return "Сб"
case time.Sunday:
return "Вс"
default:
return ""
}
}
func formatCompactDateTime(value time.Time) string {
weekday := weekdayName(value.Weekday())
month := monthShort(value.Month())
return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04"))
}
func monthShort(month time.Month) string {
months := []string{
"янв", "фев", "мар", "апр", "май", "июн",
"июл", "авг", "сен", "окт", "ноя", "дек",
}
if int(month) < 1 || int(month) > len(months) {
return ""
}
return months[int(month)-1]
}

View File

@@ -77,8 +77,8 @@ func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup {
// Первый ряд: Креативы и Закупы
buttons = append(buttons, bot.Row(
bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)),
bot.Button("Закупы", fmt.Sprintf("purchases:%s", s.Project.ID)),
bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)),
bot.Button("Закупы", fmt.Sprintf("purchases:%s", s.Project.ID)),
))
// Второй ряд: Тип вступления по умолчанию для закупов (с подтверждением)