This commit is contained in:
Artem Tsyrulnikov
2026-01-09 13:41:04 +03:00
parent a0b12dbcca
commit 6ec26c96e3
13 changed files with 164 additions and 211 deletions

View File

@@ -24,8 +24,6 @@ type AddCreative struct {
}
const msgWaitCreative = `
<i>… Креативы Добавление</i>
<b> Добавить креатив</b>
📄 <b>Пришлите креатив который хотите добавить</b>

View File

@@ -31,6 +31,7 @@ type AddProject struct {
func (s *AddProject) Enter(b *bot.Bot, mode bot.RenderMode) {
buttons := [][]echotron.InlineKeyboardButton{
Row(Button("← Назад", "back")),
Row(URLButton("Добавить", "tg://resolve?domain=chat_cruiser_bot&startchannel&admin=invite_users+post_messages+edit_messages+delete_messages")),
}
keyboard := Keyboard(buttons...)

View File

@@ -40,7 +40,7 @@ func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
}
func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt string) {
text := "<i>… Закупы Создание</i>\n\n<b>Создание закупа</b>\n\n"
text := "<b>Создание закупа</b>\n\n"
text += "Выберите проект и креатив.\n\n"
var buttons [][]echotron.InlineKeyboardButton

View File

@@ -24,15 +24,7 @@ type CreativeDetails struct {
}
func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("<b>❌ Ошибка авторизации</b>\n\nПопробуйте /start", Keyboard())
return
}
// Загружаем креатив
creative, err := b.Backend.GetCreative(context.Background(), jwt, s.WorkspaceID, s.CreativeID)
creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, s.WorkspaceID, s.CreativeID)
if err != nil {
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative")
b.SendNew("<b>❌ Не удалось загрузить креатив</b>", Keyboard(
@@ -476,25 +468,9 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
}
func (s *CreativeDetails) deleteCreative(b *bot.Bot) {
// JWT уже создан в Bot.Update(), просто берем из сессии
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("<b>❌ Ошибка авторизации</b>\n\nПопробуйте /start", Keyboard())
return
}
// Отправляем запрос на удаление
err := b.Backend.DeleteCreative(
context.Background(),
jwt,
s.WorkspaceID,
s.CreativeID,
)
err := b.Backend.DeleteCreative(context.Background(), b.Session.JWT, s.WorkspaceID, s.CreativeID)
if err != nil {
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to delete creative")
// Показываем ошибку через панель управления
s.confirmingDelete = false // Сбрасываем флаг подтверждения
s.ShowControlPanel(b, "<b>❌ Не удалось удалить креатив</b>\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{
Row(Button("← Назад", "back")),
@@ -503,9 +479,6 @@ func (s *CreativeDetails) deleteCreative(b *bot.Bot) {
return
}
log.Info().Str("creative_id", s.CreativeID).Msg("Creative deleted successfully")
// Возвращаемся назад
if s.BackState != nil {
b.SendNew("✅ Креатив успешно удален!", Keyboard())
b.SetState(s.BackState, bot.EditMessage)

View File

@@ -14,11 +14,10 @@ import (
const creativesPerPage = 6
type Creatives struct {
CurrentPage int
ProjectID string
ProjectTitle string
WorkspaceID string
BackState bot.State
CurrentPage int
ProjectID string
WorkspaceID string
BackState bot.State
}
func (s *Creatives) Enter(b *bot.Bot, mode bot.RenderMode) {
@@ -52,16 +51,8 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string)
var text string
var buttons [][]echotron.InlineKeyboardButton
// Навигация: 2 последних уровня
navPath := "… Креативы"
if s.ProjectTitle != "" {
navPath = fmt.Sprintf("… %s Креативы", s.ProjectTitle)
}
if len(page.Items) == 0 {
text = fmt.Sprintf(`<i>%s</i>
<b>Креативы</b>
text = `<b>Креативы</b>
У вас пока нет креативов
@@ -71,16 +62,13 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string)
‣ Шаблон для автопостинга
‣ Варианты для A/B тестирования
Начните с добавления первого креатива`, navPath)
Начните с добавления первого креатива`
} else {
text = fmt.Sprintf(`<i>%s</i>
<b>Креативы</b>%s
text = fmt.Sprintf(`<b>Креативы</b>%s
Управление рекламными материалами
Выберите креатив`,
navPath,
ui.FormatPageInfo(s.CurrentPage, page.Pages))
}

View File

@@ -1,814 +0,0 @@
package screens
import (
"fmt"
"regexp"
"strings"
"time"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
)
const (
pickerViewCalendar = "calendar"
pickerViewMonths = "months"
pickerViewTimeCombined = "time_combined"
pickerViewConfirm = "confirm"
dtpCallbackPrefix = "dtp:"
)
var mskLocation = time.FixedZone("MSK", 3*60*60)
type DateTimePickerConfig struct {
Title string
Key string
IncludeTime bool
Selected *time.Time
BackState bot.State
}
type DateTimeSelectionTarget interface {
SetDateTimeSelection(key string, value time.Time)
}
type DateTimePicker struct {
Title string
Key string
IncludeTime bool
BackState bot.State
view string
year int
month time.Month
selectedDate *time.Time
selectedHour *int
selectedMinute *int
}
func NewDateTimePicker(cfg DateTimePickerConfig) *DateTimePicker {
now := time.Now().In(mskLocation)
year := now.Year()
month := now.Month()
var selected *time.Time
if cfg.Selected != nil {
value := cfg.Selected.In(mskLocation)
selected = &value
year = value.Year()
month = value.Month()
}
return &DateTimePicker{
Title: cfg.Title,
Key: cfg.Key,
IncludeTime: cfg.IncludeTime,
BackState: cfg.BackState,
view: pickerViewCalendar,
year: year,
month: month,
selectedDate: selected,
}
}
func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) {
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()
b.Render(text, s.calendarKeyboard(), mode)
case pickerViewMonths:
text += "Выберите месяц\n\n"
b.Render(text, s.monthsKeyboard(), mode)
case pickerViewTimeCombined:
b.Render(text, s.timeCombinedKeyboard(), mode)
case pickerViewConfirm:
text += s.renderSelectedDateTime()
b.Render(text, s.confirmKeyboard(), mode)
}
}
func (s *DateTimePicker) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
if data == "back" {
s.handleBack(b)
return
}
if data == "cancel" {
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
return
}
if !strings.HasPrefix(data, dtpCallbackPrefix) {
s.Enter(b, bot.EditMessage)
return
}
payload := strings.TrimPrefix(data, dtpCallbackPrefix)
switch {
case payload == "month_prev":
s.prevMonth()
case payload == "month_next":
s.nextMonth()
case payload == "open_months":
s.view = pickerViewMonths
case payload == "year_prev":
s.year--
case payload == "year_next":
s.year++
case strings.HasPrefix(payload, "month:"):
month := parseInt(strings.TrimPrefix(payload, "month:"))
if month >= 1 && month <= 12 {
s.month = time.Month(month)
s.view = pickerViewCalendar
}
case strings.HasPrefix(payload, "day:"):
s.handleDaySelect(payload)
case strings.HasPrefix(payload, "hour:"):
s.handleHourSelect(payload)
case strings.HasPrefix(payload, "min:"):
s.handleMinuteSelect(payload)
case payload == "confirm":
s.confirmSelection(b)
return
}
s.Enter(b, bot.EditMessage)
}
func (s *DateTimePicker) handleBack(b *bot.Bot) {
switch s.view {
case pickerViewMonths:
s.view = pickerViewCalendar
case pickerViewTimeCombined:
s.view = pickerViewCalendar
case pickerViewConfirm:
if s.IncludeTime {
s.view = pickerViewTimeCombined
} else {
s.view = pickerViewCalendar
}
default:
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
return
}
}
s.Enter(b, bot.EditMessage)
}
func (s *DateTimePicker) renderCalendarTitle() string {
if s.year == time.Now().In(mskLocation).Year() {
return "\n"
}
return fmt.Sprintf("%d\n\n", s.year)
}
func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, Row(
s.monthPrevButton(),
Button(monthName(s.month), dtpCallbackPrefix+"open_months"),
Button("→", dtpCallbackPrefix+"month_next"),
))
rows = append(rows, Row(
Button("Пн", "empty"),
Button("Вт", "empty"),
Button("Ср", "empty"),
Button("Чт", "empty"),
Button("Пт", "empty"),
Button("Сб", "empty"),
Button("Вс", "empty"),
))
firstOfMonth := time.Date(s.year, s.month, 1, 0, 0, 0, 0, mskLocation)
weekday := int(firstOfMonth.Weekday())
if weekday == 0 {
weekday = 7
}
daysInMonth := daysInMonth(s.year, s.month)
today := time.Now().In(mskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, mskLocation)
var row []echotron.InlineKeyboardButton
for i := 1; i < weekday; i++ {
row = append(row, Button(" ", "empty"))
}
for day := 1; day <= daysInMonth; day++ {
date := time.Date(s.year, s.month, day, 0, 0, 0, 0, mskLocation)
if date.Before(todayDate) {
row = append(row, Button("-", "empty"))
} else {
label := fmt.Sprintf("%d", day)
row = append(row, Button(label, fmt.Sprintf("%sday:%04d-%02d-%02d", dtpCallbackPrefix, s.year, int(s.month), day)))
}
if len(row) == 7 {
rows = append(rows, row)
row = nil
}
}
if len(row) > 0 {
for len(row) < 7 {
row = append(row, Button(" ", "empty"))
}
rows = append(rows, row)
}
rows = append(rows, Row(
Button("← Назад", "back"),
Button("Отмена", "cancel"),
))
return Keyboard(rows...)
}
func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
months := []string{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"}
for i := 0; i < 12; i += 3 {
rows = append(rows, Row(
Button(months[i], fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+1)),
Button(months[i+1], fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+2)),
Button(months[i+2], fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+3)),
))
}
rows = append(rows, Row(
Button("← Назад", "back"),
))
return Keyboard(rows...)
}
func (s *DateTimePicker) timeCombinedKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, Row(Button("Часы", "empty")))
for i := 0; i < 24; i += 6 {
rows = append(rows, 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, Row(Button("Минуты", "empty")))
minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}
for i := 0; i < len(minutes); i += 6 {
rows = append(rows, 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, Row(
Button("← Назад", "back"),
Button("Готово", dtpCallbackPrefix+"confirm"),
))
return Keyboard(rows...)
}
func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup {
return Keyboard(
Row(
Button("← Назад", "back"),
Button("Готово", dtpCallbackPrefix+"confirm"),
),
)
}
func (s *DateTimePicker) renderSelectedDateTime() string {
if s.selectedDate == nil {
return "—"
}
hour := 0
minute := 0
if s.selectedHour != nil {
hour = *s.selectedHour
}
if s.selectedMinute != nil {
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>", formatCompactDateTime(value))
}
func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string {
hour := "__"
minute := "__"
if s.selectedHour != nil {
hour = fmt.Sprintf("%02d", *s.selectedHour)
} else if !showPlaceholders {
hour = "00"
}
if s.selectedMinute != nil {
minute = fmt.Sprintf("%02d", *s.selectedMinute)
} else if !showPlaceholders {
minute = "00"
}
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)
if err != nil {
return
}
s.selectedDate = &parsed
if s.IncludeTime {
s.view = pickerViewTimeCombined
s.selectedHour = nil
s.selectedMinute = nil
} else {
s.view = pickerViewConfirm
}
}
func (s *DateTimePicker) handleHourSelect(payload string) {
hour := parseInt(strings.TrimPrefix(payload, "hour:"))
if hour < 0 || hour > 23 {
return
}
if s.isHourDisabled(hour) {
return
}
s.selectedHour = &hour
}
func (s *DateTimePicker) handleMinuteSelect(payload string) {
minute := parseInt(strings.TrimPrefix(payload, "min:"))
if minute < 0 || minute > 59 {
return
}
if s.isMinuteDisabled(minute) {
return
}
s.selectedMinute = &minute
}
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 {
hour = *s.selectedHour
}
if s.selectedMinute != nil {
minute = *s.selectedMinute
}
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, mskLocation)
if target, ok := s.BackState.(DateTimeSelectionTarget); ok {
target.SetDateTimeSelection(s.Key, value)
}
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
}
func (s *DateTimePicker) hourButton(hour int) echotron.InlineKeyboardButton {
label := fmt.Sprintf("%02d", hour)
if s.isHourDisabled(hour) {
return Button("-", "empty")
}
if s.selectedHour != nil && *s.selectedHour == hour {
label = "● " + label
}
return Button(label, fmt.Sprintf("%shour:%02d", dtpCallbackPrefix, hour))
}
func (s *DateTimePicker) minuteButton(minute int) echotron.InlineKeyboardButton {
label := fmt.Sprintf("%02d", minute)
if s.isMinuteDisabled(minute) {
return Button("-", "empty")
}
if s.selectedMinute != nil && *s.selectedMinute == minute {
label = "● " + label
}
return Button(label, fmt.Sprintf("%smin:%02d", dtpCallbackPrefix, minute))
}
func (s *DateTimePicker) isHourDisabled(hour int) bool {
if s.selectedDate == nil {
return false
}
today := time.Now().In(mskLocation)
date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, mskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, mskLocation)
if date.After(todayDate) {
return false
}
return hour < today.Hour()
}
func (s *DateTimePicker) isMinuteDisabled(minute int) bool {
if s.selectedDate == nil || s.selectedHour == nil {
return false
}
today := time.Now().In(mskLocation)
date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, mskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, mskLocation)
if date.After(todayDate) {
return false
}
if *s.selectedHour > today.Hour() {
return false
}
return minute < today.Minute()
}
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--
} else {
s.month--
}
}
func (s *DateTimePicker) nextMonth() {
if s.month == time.December {
s.month = time.January
s.year++
} else {
s.month++
}
}
func (s *DateTimePicker) monthPrevButton() echotron.InlineKeyboardButton {
now := time.Now().In(mskLocation)
if s.year == now.Year() && s.month == now.Month() {
return Button("-", "empty")
}
return 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()
}
func monthName(month time.Month) string {
names := []string{
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
}
if int(month) < 1 || int(month) > len(names) {
return ""
}
return names[int(month)-1]
}
func parseInt(value string) int {
result := 0
for _, r := range value {
if r < '0' || r > '9' {
return 0
}
result = result*10 + int(r-'0')
}
return result
}
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

@@ -12,7 +12,7 @@ type Login struct{}
const msgHelloLogin = `
Привет, %s!
👇Нажми кнопку для входа на сайт.
Нажми кнопку для входа на сайт. 👇
`
func (s *Login) Enter(b *bot.Bot, mode bot.RenderMode) {

View File

@@ -82,7 +82,6 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string)
var text string
var buttons [][]echotron.InlineKeyboardButton
navTitle := "Главное меню Мои проекты"
headerTitle := "Мои проекты"
emptyStateText := `У вас пока нет подключенных каналов.
@@ -96,24 +95,18 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string)
Начните с добавления первого проекта`
nonEmptyText := "Управление вашими Telegram-каналами.\n\nВыберите канал"
if s.OpenPurchases {
navTitle = "Главное меню Закупы"
headerTitle = "Закупы"
nonEmptyText = "Выберите проект для просмотра закупов"
}
if len(page.Items) == 0 {
text = fmt.Sprintf(`<i>%s</i>
text = fmt.Sprintf(`<b>%s</b>
<b>%s</b>
%s`, navTitle, headerTitle, emptyStateText)
%s`, headerTitle, emptyStateText)
} else {
text = fmt.Sprintf(`<i>%s</i>
<b>%s</b>%s
text = fmt.Sprintf(`<b>%s</b>%s
%s`,
navTitle,
headerTitle,
ui.FormatPageInfo(s.CurrentPage, page.Pages),
nonEmptyText)

View File

@@ -34,9 +34,7 @@ func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
func (s *ProjectDetails) formatProjectDetails() string {
p := s.Project
// Хлебные крошки и заголовок
text := fmt.Sprintf("<i>… Мои проекты %s</i>\n\n", p.Title)
text += fmt.Sprintf("<b>%s</b>\n\n", p.Title)
text := fmt.Sprintf("<b>%s</b>\n\n", p.Title)
// Информация о канале
if p.Username != nil && *p.Username != "" {
@@ -132,10 +130,9 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
case strings.HasPrefix(data, "creatives:"):
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
b.SetState(&Creatives{
ProjectID: s.Project.ID,
ProjectTitle: s.Project.Title,
WorkspaceID: s.WorkspaceID,
BackState: s,
ProjectID: s.Project.ID,
WorkspaceID: s.WorkspaceID,
BackState: s,
}, bot.EditMessage)
case strings.HasPrefix(data, "purchases:"):

View File

@@ -12,11 +12,10 @@ import (
)
type PurchaseDetails struct {
WorkspaceID string
ProjectID string
ProjectTitle string
PurchaseID string
BackState bot.State
WorkspaceID string
ProjectID string
PurchaseID string
BackState bot.State
}
func (s *PurchaseDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
@@ -63,7 +62,7 @@ func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt str
totalPlacements += len(items)
}
text := "<i>… Закупы Детали</i>\n\n<b>Детали закупа</b>\n\n"
text := "<b>Детали закупа</b>\n\n"
// Информация о креативе
if creative != nil {

View File

@@ -70,8 +70,7 @@ func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
return
}
text := "<i>… Создание Дополнительно</i>\n\n"
text += "<b>Страница создания закупа и необязательные составляющие</b>"
text := "<b>Страница создания закупа и необязательные составляющие</b>"
text += "\n\n"
text += s.formatOptionalSummary()
@@ -129,7 +128,7 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update)
}
case "opt_payment_date":
b.SetState(NewDateTimePicker(DateTimePickerConfig{
b.SetState(ui.NewDateTimePicker(ui.DateTimePickerConfig{
Title: "Дата оплаты",
Key: "payment_date",
IncludeTime: true,
@@ -380,8 +379,8 @@ func (s *PurchaseOptionalDetails) formatDateTime(value *time.Time) string {
if value == nil {
return "—"
}
local := value.In(mskLocation)
return fmt.Sprintf("%s %02d %s %s", weekdayName(local.Weekday()), local.Day(), monthShort(local.Month()), local.Format("15:04"))
local := value.In(ui.MskLocation)
return fmt.Sprintf("%s %02d %s %s", ui.WeekdayName(local.Weekday()), local.Day(), ui.MonthShort(local.Month()), local.Format("15:04"))
}
func (s *PurchaseOptionalDetails) formatText(value string) string {
@@ -500,7 +499,7 @@ func (s *PurchaseOptionalDetails) costBeforeDeleteRow() []echotron.InlineKeyboar
}
func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderMode) {
text := "<i>… Создание Дополнительно</i>\n\n"
text := "<b>Дополнительно</b>\n\n"
switch s.InputMode {
case "type":
text += "<b>Выберите тип закупа</b>"
@@ -598,8 +597,7 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
}
func (s *PurchaseOptionalDetails) renderModeSelect(b *bot.Bot, mode bot.RenderMode) {
text := "<i>… Создание Дополнительно Выбор режима</i>\n\n"
text += "<b>⚙ Переключить режим параметра</b>\n\n"
text := "<b>⚙ Переключить режим параметра</b>\n\n"
// Показываем текущую информацию о параметрах
text += s.formatOptionalSummary()
@@ -671,8 +669,7 @@ func (s *PurchaseOptionalDetails) renderParamScreens(b *bot.Bot, mode bot.Render
func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.RenderMode, param string) {
s.CurrentParam = param
text := "<i>… Создание Дополнительно</i>\n\n"
text += fmt.Sprintf("<b>%s — по каналам</b>\n\n", s.paramTitle(param))
text := fmt.Sprintf("<b>%s — по каналам</b>\n\n", s.paramTitle(param))
text += s.renderParamChannelSummary(param) + "\n\n"
const perPage = 5
@@ -866,7 +863,10 @@ func (s *PurchaseOptionalDetails) parseParamIndex(data, prefix string) (string,
return "", 0, false
}
param := parts[0]
index := parseInt(parts[1])
index, err := strconv.Atoi(parts[1])
if err != nil {
return "", 0, false
}
return param, index, true
}
@@ -875,7 +875,7 @@ func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) {
case "placement":
s.InputMode = ""
s.ReturnMode = ""
b.SetState(NewDateTimePicker(DateTimePickerConfig{
b.SetState(ui.NewDateTimePicker(ui.DateTimePickerConfig{
Title: "Дата и время размещения",
Key: "placement_datetime",
IncludeTime: true,
@@ -907,7 +907,7 @@ func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, username
switch param {
case "placement":
s.InputMode = "placement_channels"
b.SetState(NewDateTimePicker(DateTimePickerConfig{
b.SetState(ui.NewDateTimePicker(ui.DateTimePickerConfig{
Title: "Дата и время размещения",
Key: "placement_datetime:" + username,
IncludeTime: true,
@@ -968,7 +968,7 @@ func (s *PurchaseOptionalDetails) pasteParamValue(param, username string) {
s.PlacementByChannel[username] = nil
return
}
value := s.PlacementCopy.In(mskLocation)
value := s.PlacementCopy.In(ui.MskLocation)
s.PlacementByChannel[username] = &value
case "cost":
if s.CostCopy == nil {
@@ -1467,11 +1467,10 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) {
}
b.SetState(&PurchaseDetails{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
PurchaseID: purchase.ID,
BackState: s.BackState,
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
PurchaseID: purchase.ID,
BackState: s.BackState,
}, bot.NewMessage)
}

View File

@@ -40,12 +40,7 @@ func (s *Purchases) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string)
return
}
// Навигация: 2 последних уровня
navPath := "… Закупы"
if s.ProjectTitle != "" {
navPath = fmt.Sprintf("… %s Закупы", s.ProjectTitle)
}
text := fmt.Sprintf("<i>%s</i>\n\n<b>Закупы</b>\n\n", navPath)
text := "<b>Закупы</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
@@ -136,11 +131,10 @@ func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) {
if len(data) > 9 && data[:9] == "purchase:" {
purchaseID := data[9:]
b.SetState(&PurchaseDetails{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
PurchaseID: purchaseID,
BackState: s,
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
PurchaseID: purchaseID,
BackState: s,
}, bot.EditMessage)
} else {
s.Enter(b, bot.NewMessage)

View File

@@ -38,8 +38,7 @@ func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
}
func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.RenderMode) {
text := "<i>… Создание Каналы</i>\n\n"
text += "<b>Создание закупа</b>\n\n"
text := "<b>Создание закупа</b>\n\n"
text += "<b>Шаг 2/2:</b> Добавление каналов\n\n"
var buttons [][]echotron.InlineKeyboardButton
@@ -268,11 +267,10 @@ func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
}
b.SetState(&PurchaseDetails{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
PurchaseID: purchase.ID,
BackState: s.BackState,
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
PurchaseID: purchase.ID,
BackState: s.BackState,
}, bot.EditMessage)
}