выбор времени
This commit is contained in:
@@ -306,14 +306,22 @@ func (c *Client) AcceptWorkspaceInvite(
|
||||
// ============================================================================
|
||||
|
||||
type Creative struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Text string `json:"text"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ProjectChannelTitle string `json:"project_channel_title"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Status string `json:"status"`
|
||||
PlacementsCount int `json:"placements_count"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Text string `json:"text"`
|
||||
MediaType string `json:"media_type"`
|
||||
MediaFileID string `json:"media_file_id"`
|
||||
Buttons []CreativeButton `json:"buttons"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ProjectChannelTitle string `json:"project_channel_title"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Status string `json:"status"`
|
||||
PlacementsCount int `json:"placements_count"`
|
||||
}
|
||||
|
||||
type CreativeButton struct {
|
||||
Text string `json:"text"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type CreativesPage struct {
|
||||
@@ -377,8 +385,12 @@ func (c *Client) GetCreative(
|
||||
}
|
||||
|
||||
type CreateCreativeInput struct {
|
||||
Name string `json:"name"`
|
||||
Text string `json:"text"`
|
||||
Name string `json:"name"`
|
||||
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(
|
||||
@@ -407,9 +419,13 @@ func (c *Client) CreateCreative(
|
||||
}
|
||||
|
||||
type UpdateCreativeInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Name *string `json:"name,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"`
|
||||
}
|
||||
|
||||
func (c *Client) UpdateCreative(
|
||||
|
||||
@@ -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) {
|
||||
// Удаляем кнопки у предыдущего сообщения перед отправкой нового
|
||||
if b.LastMessageID != 0 {
|
||||
|
||||
@@ -97,6 +97,7 @@ https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
|
||||
// Удаляем медиа
|
||||
s.MediaType = ""
|
||||
s.MediaFileID = ""
|
||||
s.MediaChanged = true
|
||||
s.UpdateCreativePreview(b)
|
||||
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]
|
||||
s.MediaType = "photo"
|
||||
s.MediaFileID = photo.FileID
|
||||
s.MediaChanged = true
|
||||
handled = true
|
||||
} else if u.Message.Video != nil {
|
||||
s.MediaType = "video"
|
||||
s.MediaFileID = u.Message.Video.FileID
|
||||
s.MediaChanged = true
|
||||
handled = true
|
||||
} else if u.Message.Animation != nil {
|
||||
s.MediaType = "animation"
|
||||
s.MediaFileID = u.Message.Animation.FileID
|
||||
s.MediaChanged = true
|
||||
handled = true
|
||||
}
|
||||
|
||||
@@ -307,6 +311,26 @@ func (s *AddCreative) createCreative(b *bot.Bot) {
|
||||
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
|
||||
creative, err := b.Backend.CreateCreative(
|
||||
context.Background(),
|
||||
@@ -314,8 +338,12 @@ func (s *AddCreative) createCreative(b *bot.Bot) {
|
||||
s.WorkspaceID,
|
||||
s.ProjectID,
|
||||
backend.CreateCreativeInput{
|
||||
Name: *s.Name,
|
||||
Text: *s.Text,
|
||||
Name: *s.Name,
|
||||
Text: *s.Text,
|
||||
MediaType: s.MediaType,
|
||||
MediaFileID: s.MediaFileID,
|
||||
MediaData: mediaData,
|
||||
Buttons: buttons,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -10,13 +10,20 @@ import (
|
||||
)
|
||||
|
||||
const addPurchaseCreativesPerPage = 5
|
||||
const addPurchaseProjectsPerPage = 6
|
||||
|
||||
type AddPurchase struct {
|
||||
WorkspaceID string
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
CurrentPage int
|
||||
BackState bot.State
|
||||
WorkspaceID string
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
CreativeID string
|
||||
CreativeTitle string
|
||||
ProjectPage int
|
||||
CreativePage int
|
||||
ActivePicker string // "" | "project_list" | "creative_list"
|
||||
BackState bot.State
|
||||
lastProjects map[string]string
|
||||
lastCreatives map[string]string
|
||||
}
|
||||
|
||||
func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
@@ -27,73 +34,159 @@ func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
return
|
||||
}
|
||||
|
||||
s.renderCreativeSelection(b, mode, jwt)
|
||||
s.renderSelection(b, mode, jwt)
|
||||
}
|
||||
|
||||
func (s *AddPurchase) renderCreativeSelection(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
||||
// Получаем список активных креативов
|
||||
page, err := b.Backend.GetCreatives(
|
||||
context.Background(),
|
||||
jwt,
|
||||
s.WorkspaceID,
|
||||
&s.ProjectID,
|
||||
false, // только активные
|
||||
s.CurrentPage+1,
|
||||
addPurchaseCreativesPerPage,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get creatives")
|
||||
b.SendNew("❌ Не удалось загрузить креативы", bot.Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if len(page.Items) == 0 {
|
||||
text += `Для создания закупа сначала нужно создать креатив.
|
||||
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(
|
||||
context.Background(),
|
||||
jwt,
|
||||
s.WorkspaceID,
|
||||
&s.ProjectID,
|
||||
false,
|
||||
s.CreativePage+1,
|
||||
addPurchaseCreativesPerPage,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get creatives")
|
||||
b.SendNew("❌ Не удалось загрузить креативы", bot.Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
s.lastCreatives = make(map[string]string)
|
||||
for _, creative := range page.Items {
|
||||
s.lastCreatives[creative.ID] = creative.Name
|
||||
}
|
||||
|
||||
if len(page.Items) == 0 {
|
||||
text += `Для создания закупа сначала нужно создать креатив.
|
||||
|
||||
Вернитесь назад и создайте креатив в разделе <b>Креативы</b>`
|
||||
} else {
|
||||
pageInfo := ""
|
||||
if page.Pages > 1 {
|
||||
pageInfo = fmt.Sprintf(" <i>(стр. %d/%d)</i>", page.Page, page.Pages)
|
||||
}
|
||||
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("← Назад", "back"),
|
||||
))
|
||||
} 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 _, creative := range page.Items {
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)),
|
||||
))
|
||||
}
|
||||
|
||||
var navRow []echotron.InlineKeyboardButton
|
||||
if s.CreativePage > 0 {
|
||||
navRow = append(navRow, bot.Button("←", "prev_creative"))
|
||||
}
|
||||
if s.CreativePage+1 < page.Pages {
|
||||
navRow = append(navRow, bot.Button("→", "next_creative"))
|
||||
}
|
||||
if len(navRow) > 0 {
|
||||
buttons = append(buttons, navRow)
|
||||
}
|
||||
}
|
||||
|
||||
text += fmt.Sprintf(`<b>Шаг 1/2:</b> Выбор креатива%s
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("← Назад", "back_to_selection"),
|
||||
))
|
||||
}
|
||||
|
||||
Выберите креатив для закупа
|
||||
|
||||
`, pageInfo)
|
||||
|
||||
// Кнопки с креативами
|
||||
for _, creative := range page.Items {
|
||||
if s.ActivePicker == "" {
|
||||
if s.ProjectID != "" && s.CreativeID != "" {
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)),
|
||||
bot.Button("← Отмена", "back"),
|
||||
bot.Button("→ Далее", "next"),
|
||||
))
|
||||
} else {
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("← Отмена", "back"),
|
||||
))
|
||||
}
|
||||
|
||||
// Пагинация
|
||||
var navRow []echotron.InlineKeyboardButton
|
||||
if s.CurrentPage > 0 {
|
||||
navRow = append(navRow, bot.Button("←", "prev"))
|
||||
}
|
||||
if s.CurrentPage+1 < page.Pages {
|
||||
navRow = append(navRow, bot.Button("→", "next"))
|
||||
}
|
||||
if len(navRow) > 0 {
|
||||
buttons = append(buttons, navRow)
|
||||
}
|
||||
|
||||
// Кнопка назад
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("← Отмена", "back"),
|
||||
))
|
||||
}
|
||||
|
||||
keyboard := bot.Keyboard(buttons...)
|
||||
@@ -127,30 +220,74 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
}
|
||||
|
||||
case "prev":
|
||||
if s.CurrentPage > 0 {
|
||||
s.CurrentPage--
|
||||
case "pick_project":
|
||||
s.ActivePicker = "project_list"
|
||||
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)
|
||||
|
||||
case "next":
|
||||
s.CurrentPage++
|
||||
case "next_project":
|
||||
s.ProjectPage++
|
||||
s.Enter(b, bot.EditMessage)
|
||||
|
||||
case "prev_creative":
|
||||
if s.CreativePage > 0 {
|
||||
s.CreativePage--
|
||||
}
|
||||
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{
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
CreativeID: s.CreativeID,
|
||||
CreativeTitle: s.CreativeTitle,
|
||||
Channels: []PurchaseChannelInput{},
|
||||
BackState: s.BackState,
|
||||
}, 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:]
|
||||
|
||||
// Переходим к выбору каналов
|
||||
b.SetState(&SelectChannelsForPurchase{
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
CreativeID: creativeID,
|
||||
Channels: []PurchaseChannelInput{},
|
||||
BackState: s.BackState,
|
||||
}, bot.EditMessage)
|
||||
if s.lastCreatives != nil {
|
||||
s.CreativeTitle = s.lastCreatives[creativeID]
|
||||
}
|
||||
s.CreativeID = creativeID
|
||||
s.ActivePicker = ""
|
||||
s.Enter(b, bot.EditMessage)
|
||||
return
|
||||
} else {
|
||||
s.Enter(b, bot.NewMessage)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,13 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
} else {
|
||||
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 = "… › Креативы › Детали"
|
||||
@@ -121,6 +127,7 @@ https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
|
||||
case data == "delete_media":
|
||||
s.MediaType = ""
|
||||
s.MediaFileID = ""
|
||||
s.MediaChanged = true
|
||||
s.UpdateCreativePreview(b)
|
||||
s.showManagementPanelWithDelete(b)
|
||||
|
||||
@@ -193,6 +200,7 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||
largestPhoto := photos[len(photos)-1]
|
||||
s.MediaType = "photo"
|
||||
s.MediaFileID = largestPhoto.FileID
|
||||
s.MediaChanged = true
|
||||
s.WaitingForMedia = false
|
||||
|
||||
s.UpdateCreativePreview(b)
|
||||
@@ -202,6 +210,7 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||
} else if u.Message.Video != nil {
|
||||
s.MediaType = "video"
|
||||
s.MediaFileID = u.Message.Video.FileID
|
||||
s.MediaChanged = true
|
||||
s.WaitingForMedia = false
|
||||
|
||||
s.UpdateCreativePreview(b)
|
||||
@@ -211,6 +220,7 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||
} else if u.Message.Animation != nil {
|
||||
s.MediaType = "animation"
|
||||
s.MediaFileID = u.Message.Animation.FileID
|
||||
s.MediaChanged = true
|
||||
s.WaitingForMedia = false
|
||||
|
||||
s.UpdateCreativePreview(b)
|
||||
@@ -325,6 +335,36 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
|
||||
Name: s.Name,
|
||||
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(
|
||||
|
||||
@@ -24,8 +24,9 @@ type CreativeEditorFields struct {
|
||||
Buttons []InlineButton
|
||||
|
||||
// Медиа
|
||||
MediaType string // "photo", "video", "animation"
|
||||
MediaFileID string // File ID из Telegram
|
||||
MediaType string // "photo", "video", "animation"
|
||||
MediaFileID string // File ID из Telegram
|
||||
MediaChanged bool
|
||||
|
||||
// Навигация (хлебные крошки)
|
||||
Navigation string
|
||||
|
||||
501
telegram_bot/screens/date_time_picker.go
Normal file
501
telegram_bot/screens/date_time_picker.go
Normal 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 }
|
||||
108
telegram_bot/screens/purchase_optional_details.go
Normal file
108
telegram_bot/screens/purchase_optional_details.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,7 @@ func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
ActivePicker: "",
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package screens
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"example.com/m/adapter/backend"
|
||||
@@ -18,14 +19,19 @@ type PurchaseChannelInput struct {
|
||||
}
|
||||
|
||||
type SelectChannelsForPurchase struct {
|
||||
WorkspaceID string
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
CreativeID string
|
||||
Channels []PurchaseChannelInput
|
||||
BackState bot.State
|
||||
WorkspaceID string
|
||||
ProjectID string
|
||||
ProjectTitle string
|
||||
CreativeID string
|
||||
CreativeTitle string
|
||||
Channels []PurchaseChannelInput
|
||||
CurrentPage int
|
||||
InvalidUsernames []string
|
||||
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) {
|
||||
s.renderChannelSelection(b, mode)
|
||||
}
|
||||
@@ -43,7 +49,7 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
|
||||
Отправьте username канала (без @)
|
||||
Например: <code>channel_name</code>
|
||||
|
||||
После добавления всех каналов нажмите <b>Создать закуп</b>`
|
||||
После добавления всех каналов нажмите <b>Далее</b>`
|
||||
} else {
|
||||
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 += "\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Добавьте еще каналы или создайте закуп"
|
||||
|
||||
// Кнопка создания закупа (доступна только если есть каналы)
|
||||
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
|
||||
}
|
||||
|
||||
// Кнопки навигации
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("← Назад", "back"),
|
||||
))
|
||||
if len(s.Channels) > 0 {
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("← Назад", "back"),
|
||||
bot.Button("→ Далее", "next"),
|
||||
))
|
||||
} else {
|
||||
buttons = append(buttons, bot.Row(
|
||||
bot.Button("← Назад", "back"),
|
||||
))
|
||||
}
|
||||
|
||||
keyboard := bot.Keyboard(buttons...)
|
||||
b.Render(text, keyboard, mode)
|
||||
@@ -97,13 +167,16 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
|
||||
case "back":
|
||||
// Возвращаемся к выбору креатива
|
||||
b.SetState(&AddPurchase{
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
BackState: s.BackState,
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
CreativeID: s.CreativeID,
|
||||
CreativeTitle: s.CreativeTitle,
|
||||
ActivePicker: "",
|
||||
BackState: s.BackState,
|
||||
}, bot.EditMessage)
|
||||
|
||||
case "create":
|
||||
case "next":
|
||||
if len(s.Channels) == 0 {
|
||||
b.Edit("❌ Добавьте хотя бы один канал", bot.Keyboard(
|
||||
bot.Row(bot.Button("← Назад", "back_to_select")),
|
||||
@@ -111,20 +184,47 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
|
||||
return
|
||||
}
|
||||
|
||||
jwt, err := s.ensureJWT(b)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get JWT")
|
||||
b.Edit("❌ Ошибка авторизации", bot.Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
// Создаем закуп
|
||||
s.createPurchase(b, jwt)
|
||||
b.SetState(&PurchaseOptionalDetails{
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
ProjectID: s.ProjectID,
|
||||
ProjectTitle: s.ProjectTitle,
|
||||
CreativeID: s.CreativeID,
|
||||
CreativeTitle: s.CreativeTitle,
|
||||
Channels: s.Channels,
|
||||
BackState: s.BackState,
|
||||
}, bot.EditMessage)
|
||||
|
||||
case "back_to_select":
|
||||
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:
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -182,27 +282,55 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(u.Message.Text)
|
||||
|
||||
// Убираем @ если пользователь его добавил
|
||||
username = strings.TrimPrefix(username, "@")
|
||||
|
||||
if username == "" {
|
||||
raw := strings.TrimSpace(u.Message.Text)
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем, не добавлен ли уже этот канал
|
||||
for _, ch := range s.Channels {
|
||||
if ch.Username == username {
|
||||
b.SendNew(fmt.Sprintf("⚠️ Канал @%s уже добавлен в список", username), bot.Keyboard())
|
||||
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.Trim(username, "@")
|
||||
username = strings.Trim(username, ",;")
|
||||
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
if !channelUsernameRe.MatchString(username) {
|
||||
invalid = append(invalid, username)
|
||||
continue
|
||||
}
|
||||
|
||||
exists := false
|
||||
for _, ch := range s.Channels {
|
||||
if ch.Username == username {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
s.Channels = append(s.Channels, PurchaseChannelInput{
|
||||
Username: username,
|
||||
})
|
||||
added++
|
||||
}
|
||||
|
||||
if added == 0 {
|
||||
if len(invalid) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем канал в список
|
||||
s.Channels = append(s.Channels, PurchaseChannelInput{
|
||||
Username: username,
|
||||
})
|
||||
s.InvalidUsernames = invalid
|
||||
|
||||
// Обновляем экран
|
||||
s.Enter(b, bot.NewMessage)
|
||||
|
||||
Reference in New Issue
Block a user