выбор времени

This commit is contained in:
Artem Tsyrulnikov
2026-01-03 12:41:33 +03:00
parent ab9af29f24
commit 841a8b5aaa
10 changed files with 1103 additions and 132 deletions

View File

@@ -309,6 +309,9 @@ type Creative struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Text string `json:"text"` Text string `json:"text"`
MediaType string `json:"media_type"`
MediaFileID string `json:"media_file_id"`
Buttons []CreativeButton `json:"buttons"`
ProjectID string `json:"project_id"` ProjectID string `json:"project_id"`
ProjectChannelTitle string `json:"project_channel_title"` ProjectChannelTitle string `json:"project_channel_title"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
@@ -316,6 +319,11 @@ type Creative struct {
PlacementsCount int `json:"placements_count"` PlacementsCount int `json:"placements_count"`
} }
type CreativeButton struct {
Text string `json:"text"`
URL string `json:"url"`
}
type CreativesPage struct { type CreativesPage struct {
Items []Creative `json:"items"` Items []Creative `json:"items"`
Total int `json:"total"` Total int `json:"total"`
@@ -379,6 +387,10 @@ func (c *Client) GetCreative(
type CreateCreativeInput struct { type CreateCreativeInput struct {
Name string `json:"name"` Name string `json:"name"`
Text string `json:"text"` Text string `json:"text"`
MediaType string `json:"media_type,omitempty"`
MediaFileID string `json:"media_file_id,omitempty"`
MediaData []byte `json:"media_data,omitempty"`
Buttons []CreativeButton `json:"buttons,omitempty"`
} }
func (c *Client) CreateCreative( func (c *Client) CreateCreative(
@@ -409,6 +421,10 @@ func (c *Client) CreateCreative(
type UpdateCreativeInput struct { type UpdateCreativeInput struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Text *string `json:"text,omitempty"` Text *string `json:"text,omitempty"`
MediaType *string `json:"media_type,omitempty"`
MediaFileID *string `json:"media_file_id,omitempty"`
MediaData *[]byte `json:"media_data,omitempty"`
Buttons *[]CreativeButton `json:"buttons,omitempty"`
Status *string `json:"status,omitempty"` Status *string `json:"status,omitempty"`
} }

View File

@@ -103,6 +103,17 @@ func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode R
} }
} }
func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) {
res, err := b.GetFile(fileID)
if err != nil {
return nil, err
}
if res.Result == nil || res.Result.FilePath == "" {
return nil, fmt.Errorf("telegram file not available")
}
return b.DownloadFile(res.Result.FilePath)
}
func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) { func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
// Удаляем кнопки у предыдущего сообщения перед отправкой нового // Удаляем кнопки у предыдущего сообщения перед отправкой нового
if b.LastMessageID != 0 { if b.LastMessageID != 0 {

View File

@@ -97,6 +97,7 @@ https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
// Удаляем медиа // Удаляем медиа
s.MediaType = "" s.MediaType = ""
s.MediaFileID = "" s.MediaFileID = ""
s.MediaChanged = true
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
@@ -170,14 +171,17 @@ func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) {
photo := u.Message.Photo[len(u.Message.Photo)-1] photo := u.Message.Photo[len(u.Message.Photo)-1]
s.MediaType = "photo" s.MediaType = "photo"
s.MediaFileID = photo.FileID s.MediaFileID = photo.FileID
s.MediaChanged = true
handled = true handled = true
} else if u.Message.Video != nil { } else if u.Message.Video != nil {
s.MediaType = "video" s.MediaType = "video"
s.MediaFileID = u.Message.Video.FileID s.MediaFileID = u.Message.Video.FileID
s.MediaChanged = true
handled = true handled = true
} else if u.Message.Animation != nil { } else if u.Message.Animation != nil {
s.MediaType = "animation" s.MediaType = "animation"
s.MediaFileID = u.Message.Animation.FileID s.MediaFileID = u.Message.Animation.FileID
s.MediaChanged = true
handled = true handled = true
} }
@@ -307,6 +311,26 @@ func (s *AddCreative) createCreative(b *bot.Bot) {
return return
} }
var mediaData []byte
if s.MediaFileID != "" {
mediaData, err = b.DownloadFileBytes(s.MediaFileID)
if err != nil {
log.Error().Err(err).Msg("Failed to download creative media")
b.SendNew("<b>❌ Не удалось загрузить медиа</b>", bot.Keyboard(
bot.Row(bot.Button("← Назад", "cancel")),
))
return
}
}
buttons := make([]backend.CreativeButton, 0, len(s.Buttons))
for _, button := range s.Buttons {
buttons = append(buttons, backend.CreativeButton{
Text: button.Text,
URL: button.URL,
})
}
// Создаём креатив через API // Создаём креатив через API
creative, err := b.Backend.CreateCreative( creative, err := b.Backend.CreateCreative(
context.Background(), context.Background(),
@@ -316,6 +340,10 @@ func (s *AddCreative) createCreative(b *bot.Bot) {
backend.CreateCreativeInput{ backend.CreateCreativeInput{
Name: *s.Name, Name: *s.Name,
Text: *s.Text, Text: *s.Text,
MediaType: s.MediaType,
MediaFileID: s.MediaFileID,
MediaData: mediaData,
Buttons: buttons,
}, },
) )

View File

@@ -10,13 +10,20 @@ import (
) )
const addPurchaseCreativesPerPage = 5 const addPurchaseCreativesPerPage = 5
const addPurchaseProjectsPerPage = 6
type AddPurchase struct { type AddPurchase struct {
WorkspaceID string WorkspaceID string
ProjectID string ProjectID string
ProjectTitle string ProjectTitle string
CurrentPage int CreativeID string
CreativeTitle string
ProjectPage int
CreativePage int
ActivePicker string // "" | "project_list" | "creative_list"
BackState bot.State BackState bot.State
lastProjects map[string]string
lastCreatives map[string]string
} }
func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) { func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
@@ -27,18 +34,100 @@ func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
return return
} }
s.renderCreativeSelection(b, mode, jwt) s.renderSelection(b, mode, jwt)
} }
func (s *AddPurchase) renderCreativeSelection(b *bot.Bot, mode bot.RenderMode, jwt string) { func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt string) {
// Получаем список активных креативов text := "<i>… Закупы Создание</i>\n\n<b>Создание закупа</b>\n\n"
text += "Выберите проект и креатив.\n\n"
var buttons [][]echotron.InlineKeyboardButton
projectLabel := "Проект: не выбрано"
if s.ProjectTitle != "" {
projectLabel = fmt.Sprintf("Проект: %s", s.ProjectTitle)
}
creativeLabel := "Креатив: не выбрано"
if s.CreativeTitle != "" {
creativeLabel = fmt.Sprintf("Креатив: %s", s.CreativeTitle)
}
if s.ActivePicker == "" {
buttons = append(buttons, bot.Row(
bot.Button(projectLabel, "pick_project"),
))
buttons = append(buttons, bot.Row(
bot.Button(creativeLabel, "pick_creative"),
))
}
switch s.ActivePicker {
case "project_list":
page, err := b.Backend.GetProjects(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectPage+1,
addPurchaseProjectsPerPage,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get projects")
b.SendNew("❌ Не удалось загрузить проекты", bot.Keyboard())
return
}
s.lastProjects = make(map[string]string)
for _, project := range page.Items {
s.lastProjects[project.ID] = project.Title
}
if len(page.Items) == 0 {
text += "У вас нет проектов. Создайте проект и попробуйте снова.\n\n"
} else {
pageInfo := ""
if page.Pages > 1 {
pageInfo = fmt.Sprintf(" <i>(стр. %d/%d)</i>", page.Page, page.Pages)
}
text += fmt.Sprintf("Проекты%s\n\n", pageInfo)
for _, project := range page.Items {
buttons = append(buttons, bot.Row(
bot.Button(project.Title, fmt.Sprintf("project:%s", project.ID)),
))
}
var navRow []echotron.InlineKeyboardButton
if s.ProjectPage > 0 {
navRow = append(navRow, bot.Button("←", "prev_project"))
}
if s.ProjectPage+1 < page.Pages {
navRow = append(navRow, bot.Button("→", "next_project"))
}
if len(navRow) > 0 {
buttons = append(buttons, navRow)
}
}
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back_to_selection"),
))
case "creative_list":
if s.ProjectID == "" {
text += "Сначала выберите проект, чтобы увидеть список креативов.\n\n"
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back_to_selection"),
))
break
}
page, err := b.Backend.GetCreatives( page, err := b.Backend.GetCreatives(
context.Background(), context.Background(),
jwt, jwt,
s.WorkspaceID, s.WorkspaceID,
&s.ProjectID, &s.ProjectID,
false, // только активные false,
s.CurrentPage+1, s.CreativePage+1,
addPurchaseCreativesPerPage, addPurchaseCreativesPerPage,
) )
if err != nil { if err != nil {
@@ -47,54 +136,58 @@ func (s *AddPurchase) renderCreativeSelection(b *bot.Bot, mode bot.RenderMode, j
return return
} }
text := "<i>… Закупы Создание</i>\n\n<b>Создание закупа</b>\n\n" s.lastCreatives = make(map[string]string)
for _, creative := range page.Items {
var buttons [][]echotron.InlineKeyboardButton s.lastCreatives[creative.ID] = creative.Name
}
if len(page.Items) == 0 { if len(page.Items) == 0 {
text += `Для создания закупа сначала нужно создать креатив. text += `Для создания закупа сначала нужно создать креатив.
Вернитесь назад и создайте креатив в разделе <b>Креативы</b>` Вернитесь назад и создайте креатив в разделе <b>Креативы</b>`
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back"),
))
} else { } else {
pageInfo := "" pageInfo := ""
if page.Pages > 1 { if page.Pages > 1 {
pageInfo = fmt.Sprintf(" <i>(стр. %d/%d)</i>", page.Page, page.Pages) pageInfo = fmt.Sprintf(" <i>(стр. %d/%d)</i>", page.Page, page.Pages)
} }
text += fmt.Sprintf(`<b>Шаг 1/2:</b> Выбор креатива%s text += fmt.Sprintf("Креативы%s\n\n", pageInfo)
Выберите креатив для закупа
`, pageInfo)
// Кнопки с креативами
for _, creative := range page.Items { for _, creative := range page.Items {
buttons = append(buttons, bot.Row( buttons = append(buttons, bot.Row(
bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)), bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)),
)) ))
} }
// Пагинация
var navRow []echotron.InlineKeyboardButton var navRow []echotron.InlineKeyboardButton
if s.CurrentPage > 0 { if s.CreativePage > 0 {
navRow = append(navRow, bot.Button("←", "prev")) navRow = append(navRow, bot.Button("←", "prev_creative"))
} }
if s.CurrentPage+1 < page.Pages { if s.CreativePage+1 < page.Pages {
navRow = append(navRow, bot.Button("→", "next")) navRow = append(navRow, bot.Button("→", "next_creative"))
} }
if len(navRow) > 0 { if len(navRow) > 0 {
buttons = append(buttons, navRow) buttons = append(buttons, navRow)
} }
}
// Кнопка назад buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back_to_selection"),
))
}
if s.ActivePicker == "" {
if s.ProjectID != "" && s.CreativeID != "" {
buttons = append(buttons, bot.Row(
bot.Button("← Отмена", "back"),
bot.Button("→ Далее", "next"),
))
} else {
buttons = append(buttons, bot.Row( buttons = append(buttons, bot.Row(
bot.Button("← Отмена", "back"), bot.Button("← Отмена", "back"),
)) ))
} }
}
keyboard := bot.Keyboard(buttons...) keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode) b.Render(text, keyboard, mode)
@@ -127,30 +220,74 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
b.SetState(s.BackState, bot.EditMessage) b.SetState(s.BackState, bot.EditMessage)
} }
case "prev": case "pick_project":
if s.CurrentPage > 0 { s.ActivePicker = "project_list"
s.CurrentPage-- s.Enter(b, bot.EditMessage)
case "pick_creative":
s.ActivePicker = "creative_list"
s.Enter(b, bot.EditMessage)
case "back_to_selection":
s.ActivePicker = ""
s.Enter(b, bot.EditMessage)
case "prev_project":
if s.ProjectPage > 0 {
s.ProjectPage--
} }
s.Enter(b, bot.EditMessage) s.Enter(b, bot.EditMessage)
case "next": case "next_project":
s.CurrentPage++ s.ProjectPage++
s.Enter(b, bot.EditMessage) s.Enter(b, bot.EditMessage)
default: case "prev_creative":
// Проверяем, не выбрали ли креатив if s.CreativePage > 0 {
if len(data) > 9 && data[:9] == "creative:" { s.CreativePage--
creativeID := data[9:] }
s.Enter(b, bot.EditMessage)
// Переходим к выбору каналов case "next_creative":
s.CreativePage++
s.Enter(b, bot.EditMessage)
case "next":
if s.ProjectID != "" && s.CreativeID != "" {
b.SetState(&SelectChannelsForPurchase{ b.SetState(&SelectChannelsForPurchase{
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle, ProjectTitle: s.ProjectTitle,
CreativeID: creativeID, CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle,
Channels: []PurchaseChannelInput{}, Channels: []PurchaseChannelInput{},
BackState: s.BackState, BackState: s.BackState,
}, bot.EditMessage) }, bot.EditMessage)
}
default:
if len(data) > 8 && data[:8] == "project:" {
projectID := data[8:]
if s.lastProjects != nil {
s.ProjectTitle = s.lastProjects[projectID]
}
s.ProjectID = projectID
s.CreativeID = ""
s.CreativeTitle = ""
s.CreativePage = 0
s.ActivePicker = ""
s.Enter(b, bot.EditMessage)
return
}
if len(data) > 9 && data[:9] == "creative:" {
creativeID := data[9:]
if s.lastCreatives != nil {
s.CreativeTitle = s.lastCreatives[creativeID]
}
s.CreativeID = creativeID
s.ActivePicker = ""
s.Enter(b, bot.EditMessage)
return
} else { } else {
s.Enter(b, bot.NewMessage) s.Enter(b, bot.NewMessage)
} }

View File

@@ -50,7 +50,13 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
} else { } else {
log.Info().Msg("Creative text is empty from backend") log.Info().Msg("Creative text is empty from backend")
} }
// Buttons, MediaType, MediaFileID остаются пустыми (не сохраняются в БД) s.MediaType = creative.MediaType
s.MediaFileID = creative.MediaFileID
s.Buttons = nil
for _, btn := range creative.Buttons {
s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
}
s.MediaChanged = false
// Устанавливаем навигацию // Устанавливаем навигацию
s.Navigation = "… Креативы Детали" s.Navigation = "… Креативы Детали"
@@ -121,6 +127,7 @@ https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
case data == "delete_media": case data == "delete_media":
s.MediaType = "" s.MediaType = ""
s.MediaFileID = "" s.MediaFileID = ""
s.MediaChanged = true
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b) s.showManagementPanelWithDelete(b)
@@ -193,6 +200,7 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
largestPhoto := photos[len(photos)-1] largestPhoto := photos[len(photos)-1]
s.MediaType = "photo" s.MediaType = "photo"
s.MediaFileID = largestPhoto.FileID s.MediaFileID = largestPhoto.FileID
s.MediaChanged = true
s.WaitingForMedia = false s.WaitingForMedia = false
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
@@ -202,6 +210,7 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
} else if u.Message.Video != nil { } else if u.Message.Video != nil {
s.MediaType = "video" s.MediaType = "video"
s.MediaFileID = u.Message.Video.FileID s.MediaFileID = u.Message.Video.FileID
s.MediaChanged = true
s.WaitingForMedia = false s.WaitingForMedia = false
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
@@ -211,6 +220,7 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
} else if u.Message.Animation != nil { } else if u.Message.Animation != nil {
s.MediaType = "animation" s.MediaType = "animation"
s.MediaFileID = u.Message.Animation.FileID s.MediaFileID = u.Message.Animation.FileID
s.MediaChanged = true
s.WaitingForMedia = false s.WaitingForMedia = false
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
@@ -325,6 +335,36 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
Name: s.Name, Name: s.Name,
Text: s.Text, Text: s.Text,
} }
if s.MediaChanged {
mediaType := s.MediaType
mediaFileID := s.MediaFileID
input.MediaType = &mediaType
input.MediaFileID = &mediaFileID
if s.MediaFileID != "" {
mediaData, err := b.DownloadFileBytes(s.MediaFileID)
if err != nil {
log.Error().Err(err).Msg("Failed to download creative media")
b.SendNew("<b>❌ Не удалось загрузить медиа</b>", bot.Keyboard(
bot.Row(bot.Button("← Назад", "back")),
))
return
}
input.MediaData = &mediaData
} else {
emptyData := []byte{}
input.MediaData = &emptyData
}
}
buttons := make([]backend.CreativeButton, 0, len(s.Buttons))
for _, button := range s.Buttons {
buttons = append(buttons, backend.CreativeButton{
Text: button.Text,
URL: button.URL,
})
}
input.Buttons = &buttons
// Отправляем запрос на обновление // Отправляем запрос на обновление
_, err = b.Backend.UpdateCreative( _, err = b.Backend.UpdateCreative(

View File

@@ -26,6 +26,7 @@ type CreativeEditorFields struct {
// Медиа // Медиа
MediaType string // "photo", "video", "animation" MediaType string // "photo", "video", "animation"
MediaFileID string // File ID из Telegram MediaFileID string // File ID из Telegram
MediaChanged bool
// Навигация (хлебные крошки) // Навигация (хлебные крошки)
Navigation string Navigation string

View File

@@ -0,0 +1,501 @@
package screens
import (
"fmt"
"strings"
"time"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
)
const (
pickerViewCalendar = "calendar"
pickerViewMonths = "months"
pickerViewTimeHour = "time_hour"
pickerViewTimeMin = "time_minute"
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<b>%s</b>\n\n", s.Title, s.Title)
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 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 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 pickerViewTimeHour:
s.view = pickerViewCalendar
case pickerViewTimeMin:
s.view = pickerViewTimeHour
case pickerViewConfirm:
if s.IncludeTime {
s.view = pickerViewTimeMin
} 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 {
header := monthName(s.month)
if s.selectedDate != nil {
header = fmt.Sprintf("%s\nВыбрано: %s", header, s.selectedDate.In(mskLocation).Format("02.01.2006"))
}
return header + "\n\n"
}
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"),
bot.Button(monthName(s.month), dtpCallbackPrefix+"open_months"),
bot.Button("→", dtpCallbackPrefix+"month_next"),
))
rows = append(rows, bot.Row(
bot.Button("Пн", "empty"),
bot.Button("Вт", "empty"),
bot.Button("Ср", "empty"),
bot.Button("Чт", "empty"),
bot.Button("Пт", "empty"),
bot.Button("Сб", "empty"),
bot.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, bot.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, bot.Button("-", "empty"))
} else {
label := fmt.Sprintf("%d", day)
row = append(row, bot.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, bot.Button(" ", "empty"))
}
rows = append(rows, row)
}
rows = append(rows, bot.Row(
bot.Button("← Назад", "back"),
bot.Button("Отмена", "cancel"),
))
return bot.Keyboard(rows...)
}
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 {
rows = append(rows, bot.Row(
bot.Button(months[i], fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+1)),
bot.Button(months[i+1], fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+2)),
bot.Button(months[i+2], fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+3)),
))
}
rows = append(rows, bot.Row(
bot.Button("← Назад", "back"),
))
return bot.Keyboard(rows...)
}
func (s *DateTimePicker) timeHourKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
for i := 0; i < 24; i += 4 {
rows = append(rows, bot.Row(
s.hourButton(i),
s.hourButton(i+1),
s.hourButton(i+2),
s.hourButton(i+3),
))
}
rows = append(rows, bot.Row(bot.Button("← Назад", "back")))
return bot.Keyboard(rows...)
}
func (s *DateTimePicker) timeMinuteKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}
for i := 0; i < len(minutes); i += 4 {
rows = append(rows, bot.Row(
s.minuteButton(minutes[i]),
s.minuteButton(minutes[i+1]),
s.minuteButton(minutes[i+2]),
s.minuteButton(minutes[i+3]),
))
}
rows = append(rows, bot.Row(bot.Button("← Назад", "back")))
return bot.Keyboard(rows...)
}
func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup {
return bot.Keyboard(
bot.Row(
bot.Button("← Назад", "back"),
bot.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>\n\nНажмите «Готово»", value.Format("02.01.2006 15:04"))
}
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) 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 = pickerViewTimeHour
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
s.view = pickerViewTimeMin
}
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
s.view = pickerViewConfirm
}
func (s *DateTimePicker) confirmSelection(b *bot.Bot) {
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)
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 bot.Button("-", "empty")
}
if s.selectedHour != nil && *s.selectedHour == hour {
label = "● " + label
}
return bot.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 bot.Button("-", "empty")
}
if s.selectedMinute != nil && *s.selectedMinute == minute {
label = "● " + label
}
return bot.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() {
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 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(_ *bot.Bot, _ *echotron.Update) { return }
func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return }

View File

@@ -0,0 +1,108 @@
package screens
import (
"fmt"
"time"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
)
type PurchaseOptionalDetails struct {
WorkspaceID string
ProjectID string
ProjectTitle string
CreativeID string
CreativeTitle string
Channels []PurchaseChannelInput
PlacementDateTime *time.Time
PaymentDate *time.Time
BackState bot.State
}
func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
text := "<i>… Создание Дополнительно</i>\n\n"
text += "<b>Страница создания закупа и необязательные составляющие</b>"
placementLabel := "☑ Дата и время размещения"
if s.PlacementDateTime != nil {
placementLabel = fmt.Sprintf("✅ Дата и время размещения: %s", s.PlacementDateTime.In(mskLocation).Format("02.01 15:04"))
}
paymentLabel := "Дата оплаты"
if s.PaymentDate != nil {
paymentLabel = fmt.Sprintf("✅ Дата оплаты: %s", s.PaymentDate.In(mskLocation).Format("02.01 15:04"))
}
keyboard := bot.Keyboard(
bot.Row(
bot.Button(placementLabel, "opt_datetime"),
bot.Button("☑ Стоимость: [цена)", "opt_cost"),
),
bot.Row(
bot.Button(paymentLabel, "opt_payment_date"),
bot.Button("Тип закупа [вп / стандарт]", "opt_type"),
),
bot.Row(
bot.Button("Формат: [формат)", "opt_format"),
bot.Button("Комментарий", "opt_comment"),
),
bot.Row(
bot.Button("Назад", "back"),
bot.Button("Далее", "next"),
),
)
b.Render(text, keyboard, mode)
}
func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
switch u.CallbackQuery.Data {
case "opt_datetime":
b.SetState(NewDateTimePicker(DateTimePickerConfig{
Title: "Дата и время размещения",
Key: "placement_datetime",
IncludeTime: true,
Selected: s.PlacementDateTime,
BackState: s,
}), bot.EditMessage)
case "opt_payment_date":
b.SetState(NewDateTimePicker(DateTimePickerConfig{
Title: "Дата оплаты",
Key: "payment_date",
IncludeTime: true,
Selected: s.PaymentDate,
BackState: s,
}), bot.EditMessage)
case "back":
b.SetState(&SelectChannelsForPurchase{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle,
Channels: s.Channels,
BackState: s.BackState,
}, bot.EditMessage)
default:
s.Enter(b, bot.EditMessage)
}
}
func (s *PurchaseOptionalDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PurchaseOptionalDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PurchaseOptionalDetails) SetDateTimeSelection(key string, value time.Time) {
switch key {
case "placement_datetime":
s.PlacementDateTime = &value
case "payment_date":
s.PaymentDate = &value
}
}

View File

@@ -141,6 +141,7 @@ func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) {
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle, ProjectTitle: s.ProjectTitle,
ActivePicker: "",
BackState: s, BackState: s,
}, bot.EditMessage) }, bot.EditMessage)

View File

@@ -3,6 +3,7 @@ package screens
import ( import (
"context" "context"
"fmt" "fmt"
"regexp"
"strings" "strings"
"example.com/m/adapter/backend" "example.com/m/adapter/backend"
@@ -22,10 +23,15 @@ type SelectChannelsForPurchase struct {
ProjectID string ProjectID string
ProjectTitle string ProjectTitle string
CreativeID string CreativeID string
CreativeTitle string
Channels []PurchaseChannelInput Channels []PurchaseChannelInput
CurrentPage int
InvalidUsernames []string
BackState bot.State BackState bot.State
} }
var channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`)
func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) { func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
s.renderChannelSelection(b, mode) s.renderChannelSelection(b, mode)
} }
@@ -43,7 +49,7 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
Отправьте username канала (без @) Отправьте username канала (без @)
Например: <code>channel_name</code> Например: <code>channel_name</code>
После добавления всех каналов нажмите <b>Создать закуп</b>` После добавления всех каналов нажмите <b>Далее</b>`
} else { } else {
text += fmt.Sprintf("Добавлено каналов: <b>%d</b>\n\n", len(s.Channels)) text += fmt.Sprintf("Добавлено каналов: <b>%d</b>\n\n", len(s.Channels))
@@ -54,19 +60,83 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
} }
text += channelText + "\n" text += channelText + "\n"
} }
text += "\n"
const channelsPerPage = 6
start := s.CurrentPage * channelsPerPage
if start >= len(s.Channels) {
s.CurrentPage = 0
start = 0
}
end := start + channelsPerPage
if end > len(s.Channels) {
end = len(s.Channels)
}
var slots []echotron.InlineKeyboardButton
for i := start; i < end; i++ {
ch := s.Channels[i]
buttonText := fmt.Sprintf("@%s", ch.Username)
if ch.PlannedCost != nil {
buttonText = fmt.Sprintf("@%s — %.0f₽", ch.Username, *ch.PlannedCost)
}
slots = append(slots, bot.Button(buttonText, fmt.Sprintf("remove_channel:%d", i)))
}
if len(s.Channels) > channelsPerPage {
for len(slots) < channelsPerPage {
slots = append(slots, bot.Button(" ", "empty"))
}
}
for i := 0; i < len(slots); i += 2 {
row := []echotron.InlineKeyboardButton{slots[i]}
if i+1 < len(slots) {
row = append(row, slots[i+1])
} else {
row = append(row, bot.Button(" ", "empty"))
}
buttons = append(buttons, row)
}
pages := (len(s.Channels) + channelsPerPage - 1) / channelsPerPage
if pages > 1 {
var navRow []echotron.InlineKeyboardButton
if s.CurrentPage > 0 {
navRow = append(navRow, bot.Button("←", "prev_page"))
} else {
navRow = append(navRow, bot.Button("·", "empty"))
}
navRow = append(navRow, bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, pages), "page_info"))
if s.CurrentPage+1 < pages {
navRow = append(navRow, bot.Button("→", "next_page"))
} else {
navRow = append(navRow, bot.Button("·", "empty"))
}
buttons = append(buttons, navRow)
}
text += "\nДобавьте еще каналы или создайте закуп" text += "\nДобавьте еще каналы или создайте закуп"
// Кнопка создания закупа (доступна только если есть каналы) // Кнопка создания закупа (доступна только если есть каналы)
buttons = append(buttons, bot.Row( }
bot.Button("● Создать закуп", "create"),
)) if len(s.InvalidUsernames) > 0 {
text += fmt.Sprintf("\n\n⚠ Пропущены: %s", strings.Join(s.InvalidUsernames, ", "))
s.InvalidUsernames = nil
} }
// Кнопки навигации // Кнопки навигации
if len(s.Channels) > 0 {
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back"),
bot.Button("→ Далее", "next"),
))
} else {
buttons = append(buttons, bot.Row( buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back"), bot.Button("← Назад", "back"),
)) ))
}
keyboard := bot.Keyboard(buttons...) keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode) b.Render(text, keyboard, mode)
@@ -100,10 +170,13 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle, ProjectTitle: s.ProjectTitle,
CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle,
ActivePicker: "",
BackState: s.BackState, BackState: s.BackState,
}, bot.EditMessage) }, bot.EditMessage)
case "create": case "next":
if len(s.Channels) == 0 { if len(s.Channels) == 0 {
b.Edit("❌ Добавьте хотя бы один канал", bot.Keyboard( b.Edit("❌ Добавьте хотя бы один канал", bot.Keyboard(
bot.Row(bot.Button("← Назад", "back_to_select")), bot.Row(bot.Button("← Назад", "back_to_select")),
@@ -111,20 +184,47 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
return return
} }
jwt, err := s.ensureJWT(b) b.SetState(&PurchaseOptionalDetails{
if err != nil { WorkspaceID: s.WorkspaceID,
log.Error().Err(err).Msg("Failed to get JWT") ProjectID: s.ProjectID,
b.Edit("❌ Ошибка авторизации", bot.Keyboard()) ProjectTitle: s.ProjectTitle,
return CreativeID: s.CreativeID,
} CreativeTitle: s.CreativeTitle,
Channels: s.Channels,
// Создаем закуп BackState: s.BackState,
s.createPurchase(b, jwt) }, bot.EditMessage)
case "back_to_select": case "back_to_select":
s.Enter(b, bot.EditMessage) s.Enter(b, bot.EditMessage)
case "prev_page":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case "next_page":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
default: default:
if len(data) > 15 && data[:15] == "remove_channel:" {
var index int
if _, err := fmt.Sscanf(data[15:], "%d", &index); err == nil {
if index >= 0 && index < len(s.Channels) {
s.Channels = append(s.Channels[:index], s.Channels[index+1:]...)
if s.CurrentPage > 0 {
channelsPerPage := 6
pages := (len(s.Channels) + channelsPerPage - 1) / channelsPerPage
if s.CurrentPage >= pages {
s.CurrentPage = pages - 1
}
}
s.Enter(b, bot.EditMessage)
return
}
}
}
s.Enter(b, bot.NewMessage) s.Enter(b, bot.NewMessage)
} }
} }
@@ -182,27 +282,55 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
return return
} }
username := strings.TrimSpace(u.Message.Text) raw := strings.TrimSpace(u.Message.Text)
if raw == "" {
return
}
// Убираем @ если пользователь его добавил tokens := strings.FieldsFunc(raw, func(r rune) bool {
return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';'
})
added := 0
var invalid []string
for _, token := range tokens {
username := strings.TrimSpace(token)
username = strings.TrimPrefix(username, "@") username = strings.TrimPrefix(username, "@")
username = strings.Trim(username, "@")
username = strings.Trim(username, ",;")
if username == "" { if username == "" {
return continue
}
if !channelUsernameRe.MatchString(username) {
invalid = append(invalid, username)
continue
} }
// Проверяем, не добавлен ли уже этот канал exists := false
for _, ch := range s.Channels { for _, ch := range s.Channels {
if ch.Username == username { if ch.Username == username {
b.SendNew(fmt.Sprintf("⚠️ Канал @%s уже добавлен в список", username), bot.Keyboard()) exists = true
return break
} }
} }
if exists {
continue
}
// Добавляем канал в список
s.Channels = append(s.Channels, PurchaseChannelInput{ s.Channels = append(s.Channels, PurchaseChannelInput{
Username: username, Username: username,
}) })
added++
}
if added == 0 {
if len(invalid) == 0 {
return
}
}
s.InvalidUsernames = invalid
// Обновляем экран // Обновляем экран
s.Enter(b, bot.NewMessage) s.Enter(b, bot.NewMessage)