bot changes

This commit is contained in:
Artem Tsyrulnikov
2026-01-03 11:24:24 +03:00
parent 0577d2a00e
commit 922067d194
13 changed files with 312 additions and 182 deletions

View File

@@ -19,6 +19,11 @@ func Button(text, data string) echotron.InlineKeyboardButton {
func Row(buttons ...echotron.InlineKeyboardButton) []echotron.InlineKeyboardButton { return buttons } func Row(buttons ...echotron.InlineKeyboardButton) []echotron.InlineKeyboardButton { return buttons }
func Keyboard(rows ...[]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup { func Keyboard(rows ...[]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
if len(rows) == 0 {
return echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{},
}
}
return echotron.InlineKeyboardMarkup{ return echotron.InlineKeyboardMarkup{
InlineKeyboard: rows, InlineKeyboard: rows,
} }

View File

@@ -20,14 +20,16 @@ type AddCreative struct {
} }
func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) { func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) {
text := `<b> Добавить креатив</b> text := `<i>… Креативы Добавление</i>
<b> Добавить креатив</b>
Введите <b>название</b> креатива: Введите <b>название</b> креатива:
<i>Например:</i> <i>Например:</i>
Летняя акция Летняя акция
Баннер 1920x1080 Баннер 1920x1080
Видео промо` Видео промо`
buttons := [][]echotron.InlineKeyboardButton{ buttons := [][]echotron.InlineKeyboardButton{
bot.Row(bot.Button("« Отмена", "cancel")), bot.Row(bot.Button("« Отмена", "cancel")),
@@ -286,6 +288,9 @@ func (s *AddCreative) Handle(_ *bot.Bot, _ *echotron.Update) { return }
// sendCreativeAndPanel отправляет превью креатива и панель управления // sendCreativeAndPanel отправляет превью креатива и панель управления
func (s *AddCreative) sendCreativeAndPanel(b *bot.Bot) { func (s *AddCreative) sendCreativeAndPanel(b *bot.Bot) {
// Устанавливаем навигацию
s.Navigation = "… Креативы Добавление"
// 1. Отправляем превью креатива // 1. Отправляем превью креатива
s.SendCreativePreview(b) s.SendCreativePreview(b)

View File

@@ -14,6 +14,7 @@ const addPurchaseCreativesPerPage = 5
type AddPurchase struct { type AddPurchase struct {
WorkspaceID string WorkspaceID string
ProjectID string ProjectID string
ProjectTitle string
CurrentPage int CurrentPage int
BackState bot.State BackState bot.State
} }
@@ -46,14 +47,14 @@ func (s *AddPurchase) renderCreativeSelection(b *bot.Bot, mode bot.RenderMode, j
return return
} }
text := "<b> Создание закупа</b>\n\n" text := "<i>… Закупы Создание</i>\n\n<b>Создание закупа</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton var buttons [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 { if len(page.Items) == 0 {
text += `Для создания закупа сначала нужно создать креатив. text += `Для создания закупа сначала нужно создать креатив.
<i>Вернитесь назад и создайте креатив в разделе "Креативы"</i>` Вернитесь назад и создайте креатив в разделе <b>Креативы</b>`
buttons = append(buttons, bot.Row( buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back"), bot.Button("← Назад", "back"),
@@ -61,12 +62,12 @@ func (s *AddPurchase) renderCreativeSelection(b *bot.Bot, mode bot.RenderMode, j
} else { } else {
pageInfo := "" pageInfo := ""
if page.Pages > 1 { if page.Pages > 1 {
pageInfo = fmt.Sprintf(" (стр. %d/%d)", 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(`<b>Шаг 1/2:</b> Выбор креатива%s
<i>Выберите креатив для закупа:</i> Выберите креатив для закупа
`, pageInfo) `, pageInfo)
@@ -145,6 +146,7 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
b.SetState(&SelectChannelsForPurchase{ b.SetState(&SelectChannelsForPurchase{
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
CreativeID: creativeID, CreativeID: creativeID,
Channels: []PurchaseChannelInput{}, Channels: []PurchaseChannelInput{},
BackState: s.BackState, BackState: s.BackState,

View File

@@ -52,6 +52,9 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
} }
// Buttons, MediaType, MediaFileID остаются пустыми (не сохраняются в БД) // Buttons, MediaType, MediaFileID остаются пустыми (не сохраняются в БД)
// Устанавливаем навигацию
s.Navigation = "… Креативы Детали"
log.Info(). log.Info().
Str("creative_id", s.CreativeID). Str("creative_id", s.CreativeID).
Str("name", creative.Name). Str("name", creative.Name).

View File

@@ -2,6 +2,7 @@ package screens
import ( import (
"fmt" "fmt"
"sort"
"strings" "strings"
"example.com/m/bot" "example.com/m/bot"
@@ -26,6 +27,9 @@ type CreativeEditorFields struct {
MediaType string // "photo", "video", "animation" MediaType string // "photo", "video", "animation"
MediaFileID string // File ID из Telegram MediaFileID string // File ID из Telegram
// Навигация (хлебные крошки)
Navigation string
// Состояние для добавления кнопки // Состояние для добавления кнопки
WaitingForButtonText bool WaitingForButtonText bool
WaitingForButtonURL bool WaitingForButtonURL bool
@@ -43,6 +47,13 @@ type CreativeEditorFields struct {
// GetCreativeText возвращает текст для превью креатива // GetCreativeText возвращает текст для превью креатива
func (e *CreativeEditorFields) GetCreativeText() string { func (e *CreativeEditorFields) GetCreativeText() string {
var result string
// Добавляем навигацию если она есть
if e.Navigation != "" {
result = fmt.Sprintf("<i>%s</i>\n\n", e.Navigation)
}
if e.Text != nil && *e.Text != "" { if e.Text != nil && *e.Text != "" {
// Проверяем что текст не является "визуально пустым" HTML // Проверяем что текст не является "визуально пустым" HTML
// Например: "<a class=\"tg-link\"></a>" - пустая ссылка // Например: "<a class=\"tg-link\"></a>" - пустая ссылка
@@ -51,7 +62,7 @@ func (e *CreativeEditorFields) GetCreativeText() string {
// Убираем пустые HTML-теги (простая проверка) // Убираем пустые HTML-теги (простая проверка)
if text == "<a class=\"tg-link\"></a>" || text == "" { if text == "<a class=\"tg-link\"></a>" || text == "" {
log.Info().Str("text", *e.Text).Msg("GetCreativeText: text is visually empty, using default") log.Info().Str("text", *e.Text).Msg("GetCreativeText: text is visually empty, using default")
return `<i>💭 Я твой креатив return result + `<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже. У меня пока нет текста, добавь его ниже.
@@ -59,10 +70,10 @@ func (e *CreativeEditorFields) GetCreativeText() string {
} }
log.Info().Str("text", *e.Text).Msg("GetCreativeText: returning creative text") log.Info().Str("text", *e.Text).Msg("GetCreativeText: returning creative text")
return *e.Text return result + *e.Text
} }
log.Info().Bool("text_is_nil", e.Text == nil).Msg("GetCreativeText: returning default text") log.Info().Bool("text_is_nil", e.Text == nil).Msg("GetCreativeText: returning default text")
return `<i>💭 Я твой креатив return result + `<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже. У меня пока нет текста, добавь его ниже.
@@ -361,54 +372,89 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin
return message.Text return message.Text
} }
type tagSpan struct {
open string
close string
start int
end int
len int
}
// Преобразуем текст в руны для правильной работы с UTF-8 // Преобразуем текст в руны для правильной работы с UTF-8
runes := []rune(message.Text) runes := []rune(message.Text)
result := ""
lastPos := 0
// Обрабатываем entities var spans []tagSpan
for _, entity := range message.Entities { for _, entity := range message.Entities {
// Добавляем текст до entity // URL уже присутствует в сыром тексте, чтобы не дублировать - пропускаем entity.
if entity.Offset > lastPos { if entity.Type == "url" {
result += string(runes[lastPos:entity.Offset]) continue
} }
// Получаем текст entity entityText := ""
entityText := string(runes[entity.Offset : entity.Offset+entity.Length]) if entity.Offset+entity.Length <= len(runes) {
entityText = string(runes[entity.Offset : entity.Offset+entity.Length])
}
// Оборачиваем в соответствующие HTML-теги var openTag, closeTag string
switch entity.Type { switch entity.Type {
case "bold": case "bold":
result += "<b>" + entityText + "</b>" openTag, closeTag = "<b>", "</b>"
case "italic": case "italic":
result += "<i>" + entityText + "</i>" openTag, closeTag = "<i>", "</i>"
case "underline": case "underline":
result += "<u>" + entityText + "</u>" openTag, closeTag = "<u>", "</u>"
case "strikethrough": case "strikethrough":
result += "<s>" + entityText + "</s>" openTag, closeTag = "<s>", "</s>"
case "code": case "code":
result += "<code>" + entityText + "</code>" openTag, closeTag = "<code>", "</code>"
case "pre": case "pre":
result += "<pre>" + entityText + "</pre>" openTag, closeTag = "<pre>", "</pre>"
case "text_link": case "text_link":
if entity.URL != "" { if entity.URL != "" {
result += fmt.Sprintf("<a href=\"%s\">%s</a>", entity.URL, entityText) openTag = fmt.Sprintf("<a href=\"%s\">", entity.URL)
} else { closeTag = "</a>"
result += entityText
} }
case "url":
result += entityText
default: default:
result += entityText _ = entityText
} }
lastPos = entity.Offset + entity.Length if openTag != "" && closeTag != "" {
spans = append(spans, tagSpan{
open: openTag,
close: closeTag,
start: entity.Offset,
end: entity.Offset + entity.Length,
len: entity.Length,
})
}
} }
// Добавляем оставшийся текст opens := make(map[int][]tagSpan)
if lastPos < len(runes) { closes := make(map[int][]tagSpan)
result += string(runes[lastPos:]) for _, span := range spans {
opens[span.start] = append(opens[span.start], span)
closes[span.end] = append(closes[span.end], span)
} }
return result // Пишем текст один раз, вставляя HTML-теги по позициям
var result strings.Builder
for i := 0; i <= len(runes); i++ {
if closing, ok := closes[i]; ok {
sort.Slice(closing, func(a, b int) bool { return closing[a].len < closing[b].len })
for _, span := range closing {
result.WriteString(span.close)
}
}
if opening, ok := opens[i]; ok {
sort.Slice(opening, func(a, b int) bool { return opening[a].len > opening[b].len })
for _, span := range opening {
result.WriteString(span.open)
}
}
if i < len(runes) {
result.WriteRune(runes[i])
}
}
return result.String()
} }

View File

@@ -15,6 +15,7 @@ const creativesPerPage = 6
type Creatives struct { type Creatives struct {
CurrentPage int CurrentPage int
ProjectID string ProjectID string
ProjectTitle string
WorkspaceID string WorkspaceID string
BackState bot.State BackState bot.State
} }
@@ -49,29 +50,40 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string)
var text string var text string
var buttons [][]echotron.InlineKeyboardButton var buttons [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 { // Навигация: 2 последних уровня
text = `<b>Креативы</b> navPath := "… Креативы"
if s.ProjectTitle != "" {
navPath = fmt.Sprintf("… %s Креативы", s.ProjectTitle)
}
У вас пока нет креативов. if len(page.Items) == 0 {
text = fmt.Sprintf(`<i>%s</i>
<b>Креативы</b>
У вас пока нет креативов
<i>Креатив — это рекламный материал:</i> <i>Креатив — это рекламный материал:</i>
Текст объявления Текст объявления
Медиа контент (фото, видео) Медиа контент (фото, видео)
Шаблон для автопостинга Шаблон для автопостинга
Варианты для A/B тестирования Варианты для A/B тестирования
<i>Начните с добавления первого креатива 👇</i>` Начните с добавления первого креатива`, navPath)
} else { } else {
pageInfo := "" pageInfo := ""
if page.Pages > 1 { if page.Pages > 1 {
pageInfo = fmt.Sprintf("\n<i>Страница %d из %d</i>", page.Page, page.Pages) pageInfo = fmt.Sprintf(" <i>(стр. %d/%d)</i>", page.Page, page.Pages)
} }
text = fmt.Sprintf(`<b>Креативы</b> text = fmt.Sprintf(`<i>%s</i>
Управление рекламными материалами.%s <b>Креативы</b>%s
<i>Выберите креатив 👇</i>`, Управление рекламными материалами
Выберите креатив`,
navPath,
pageInfo) pageInfo)
} }

View File

@@ -9,23 +9,27 @@ type MainMenu struct{}
func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) { func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
keyboard := bot.Keyboard( keyboard := bot.Keyboard(
bot.Row(bot.Button("Мои проекты", "my_projects"), bot.Button("Поддержка", "support")), bot.Row(
bot.Row(bot.Button("Закупы", "placements"), bot.Button("Дашборд", "dashboard")), bot.Button("Мои проекты", "my_projects"),
echotron.InlineKeyboardButton{Text: "Поддержка", URL: "https://example.com"},
),
bot.Row(
bot.Button("Закупы", "purchases"),
echotron.InlineKeyboardButton{Text: "Дашборд", URL: "https://example.com"},
),
) )
b.Render(tgexAbout, keyboard, mode) text := "<i>Главное меню</i>\n\n" + tgexAbout
b.Render(text, keyboard, mode)
} }
func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) { func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
switch u.CallbackQuery.Data { switch u.CallbackQuery.Data {
case "my_projects": case "my_projects":
b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.NewMessage) b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.NewMessage)
case "support": case "purchases":
// b.SetState(&Support{}, bot.NewMessage) b.SetState(&MyProjects{BackState: &MainMenu{}, OpenPurchases: true}, bot.NewMessage)
case "placements":
b.SetState(&MyPlacements{BackState: &MainMenu{}}, bot.NewMessage)
case "dashboard":
// b.SetState(&Dashboard{}, bot.NewMessage)
default: default:
s.Enter(b, bot.NewMessage) s.Enter(b, bot.NewMessage)
} }

View File

@@ -66,16 +66,20 @@ func (s *MyPlacements) renderPlacements(b *bot.Bot, mode bot.RenderMode, jwt str
return return
} }
text := "<b>📍 Мои размещения</b>\n\n" text := "<i>… Главное меню Размещения</i>\n\n<b>Мои размещения</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton var buttons [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 { if len(page.Items) == 0 {
text += `У вас пока нет размещений. text += `У вас пока нет размещений
<i>Размещения создаются автоматически при создании закупа.</i>` Размещения создаются автоматически при создании закупа`
} else { } else {
text += fmt.Sprintf("Выберите размещение для деталей (стр. %d из %d):\n\n", s.CurrentPage+1, page.Pages) pageInfo := ""
if page.Pages > 1 {
pageInfo = fmt.Sprintf(" <i>(стр. %d/%d)</i>", s.CurrentPage+1, page.Pages)
}
text += fmt.Sprintf("Выберите размещение для деталей%s\n\n", pageInfo)
// Размещения по 2 в ряду // Размещения по 2 в ряду
for i := 0; i < len(page.Items); i += 2 { for i := 0; i < len(page.Items); i += 2 {
@@ -131,7 +135,7 @@ func (s *MyPlacements) renderPlacements(b *bot.Bot, mode bot.RenderMode, jwt str
// Кнопки управления // Кнопки управления
buttons = append(buttons, bot.Row( buttons = append(buttons, bot.Row(
bot.Button("📊 Статистика", "statistics"), bot.Button(" Статистика", "statistics"),
)) ))
// Кнопка возврата в главное меню // Кнопка возврата в главное меню

View File

@@ -16,6 +16,7 @@ type MyProjects struct {
CurrentPage int CurrentPage int
WorkspaceID string WorkspaceID string
BackState bot.State BackState bot.State
OpenPurchases bool
} }
func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) { func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) {
@@ -58,11 +59,9 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string)
var text string var text string
var buttons [][]echotron.InlineKeyboardButton var buttons [][]echotron.InlineKeyboardButton
navTitle := "Главное меню Мои проекты"
if len(page.Items) == 0 { headerTitle := "Мои проекты"
text = `<b>Мои проекты</b> emptyStateText := `У вас пока нет подключенных каналов.
У вас пока нет подключенных каналов.
<i>Что дает подключение канала:</i> <i>Что дает подключение канала:</i>
▸ Управление типом приглашений (публичные/с одобрением) ▸ Управление типом приглашений (публичные/с одобрением)
@@ -71,19 +70,35 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string)
▸ Статистика и аналитика подписчиков ▸ Статистика и аналитика подписчиков
▸ Настройка уведомлений ▸ Настройка уведомлений
<i>Начните с добавления первого проекта ↓</i>` Начните с добавления первого проекта`
nonEmptyText := "Управление вашими Telegram-каналами.\n\nВыберите канал"
if s.OpenPurchases {
navTitle = "Главное меню Закупы"
headerTitle = "Закупы"
nonEmptyText = "Выберите проект для просмотра закупов"
}
if len(page.Items) == 0 {
text = fmt.Sprintf(`<i>%s</i>
<b>%s</b>
%s`, navTitle, headerTitle, emptyStateText)
} else { } else {
pageInfo := "" pageInfo := ""
if page.Pages > 1 { if page.Pages > 1 {
pageInfo = fmt.Sprintf("\n<i>Страница %d из %d</i>", page.Page, page.Pages) pageInfo = fmt.Sprintf(" <i>(стр. %d/%d)</i>", page.Page, page.Pages)
} }
text = fmt.Sprintf(`<b>Мои проекты</b> text = fmt.Sprintf(`<i>%s</i>
Управление вашими Telegram-каналами.%s <b>%s</b>%s
<i>Выберите канал 👇 </i>`, %s`,
pageInfo) navTitle,
headerTitle,
pageInfo,
nonEmptyText)
} }
// Создаем слоты для проектов // Создаем слоты для проектов
@@ -216,12 +231,21 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
return return
} }
if s.OpenPurchases {
b.SetState(&Purchases{
WorkspaceID: s.WorkspaceID,
ProjectID: project.ID,
ProjectTitle: project.Title,
BackState: &MyProjects{WorkspaceID: s.WorkspaceID, OpenPurchases: true},
}, bot.NewMessage)
} else {
b.SetState(&ProjectDetails{ b.SetState(&ProjectDetails{
Project: project, Project: project,
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
BackState: &MyProjects{WorkspaceID: s.WorkspaceID}, BackState: &MyProjects{WorkspaceID: s.WorkspaceID},
}, bot.NewMessage) }, bot.NewMessage)
} }
}
case strings.HasPrefix(data, "workspace:"): case strings.HasPrefix(data, "workspace:"):
// Позволяет переключаться между workspace'ами в будущем // Позволяет переключаться между workspace'ами в будущем

View File

@@ -34,33 +34,40 @@ func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
func (s *ProjectDetails) formatProjectDetails() string { func (s *ProjectDetails) formatProjectDetails() string {
p := s.Project p := s.Project
// Заголовок с названием проекта // Хлебные крошки и заголовок
text := fmt.Sprintf("<b>%s</b>\n\n", p.Title) text := fmt.Sprintf("<i>… Мои проекты %s</i>\n\n", p.Title)
text += fmt.Sprintf("<b>%s</b>\n\n", p.Title)
// Информация о канале // Информация о канале
if p.Username != nil && *p.Username != "" { if p.Username != nil && *p.Username != "" {
text += fmt.Sprintf("Telegram-канал: @%s\n", *p.Username) text += fmt.Sprintf("Telegram: @%s\n", *p.Username)
} else { } else {
text += "Telegram-канал: <i>не привязан</i>\n" text += "Telegram: <i>не привязан</i>\n"
} }
// Статус проекта // Статус проекта
var statusSymbol string
var statusText string var statusText string
switch p.Status { switch p.Status {
case "active": case "active":
statusText = "✓ Активный" statusSymbol = ""
statusText = "Активный"
case "inactive": case "inactive":
statusText = "○ Неактивен" statusSymbol = "○"
statusText = "Неактивен"
case "archived": case "archived":
statusText = "▣ Архивный" statusSymbol = ""
statusText = "Архивный"
case "paused": case "paused":
statusText = "⊙ Приостановлен" statusSymbol = ""
statusText = "Приостановлен"
default: default:
statusSymbol = "○"
statusText = p.Status statusText = p.Status
} }
text += fmt.Sprintf("Статус: %s\n", statusText) text += fmt.Sprintf("Статус: %s %s\n", statusSymbol, statusText)
text += "\n<i>Управление проектом 👇</i>" text += "\nУправление проектом"
return text return text
} }
@@ -126,6 +133,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
b.SetState(&Creatives{ b.SetState(&Creatives{
ProjectID: s.Project.ID, ProjectID: s.Project.ID,
ProjectTitle: s.Project.Title,
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
BackState: s, BackState: s,
}, bot.NewMessage) }, bot.NewMessage)
@@ -135,6 +143,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
b.SetState(&Purchases{ b.SetState(&Purchases{
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
ProjectID: s.Project.ID, ProjectID: s.Project.ID,
ProjectTitle: s.Project.Title,
BackState: s, BackState: s,
}, bot.NewMessage) }, bot.NewMessage)

View File

@@ -12,6 +12,7 @@ import (
type PurchaseDetails struct { type PurchaseDetails struct {
WorkspaceID string WorkspaceID string
ProjectID string ProjectID string
ProjectTitle string
PurchaseID string PurchaseID string
BackState bot.State BackState bot.State
} }
@@ -53,21 +54,23 @@ func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt str
creative = nil creative = nil
} }
text := "<b>📦 Детали закупа</b>\n\n" text := "<i>… Закупы Детали</i>\n\n<b>Детали закупа</b>\n\n"
// Информация о креативе // Информация о креативе
if creative != nil { if creative != nil {
text += fmt.Sprintf("<b>Креатив:</b> %s\n", creative.Name) text += fmt.Sprintf("<b>Креатив:</b> %s\n", creative.Name)
} else { } else {
text += fmt.Sprintf("<b>Креатив ID:</b> %s\n", purchase.CreativeID) text += fmt.Sprintf("<b>Креатив ID:</b> %s\n", purchase.CreativeID)
} }
// Статус закупа // Статус закупа
statusSymbol := "●"
statusText := "Активный" statusText := "Активный"
if purchase.Status == "archived" { if purchase.Status == "archived" {
statusSymbol = "■"
statusText = "Архивирован" statusText = "Архивирован"
} }
text += fmt.Sprintf("<b>Статус:</b> %s\n\n", statusText) text += fmt.Sprintf("<b>Статус:</b> %s %s\n\n", statusSymbol, statusText)
// Информация о каналах // Информация о каналах
if len(purchase.Channels) == 0 { if len(purchase.Channels) == 0 {
@@ -76,19 +79,19 @@ func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt str
text += fmt.Sprintf("<b>Каналы (%d):</b>\n\n", len(purchase.Channels)) text += fmt.Sprintf("<b>Каналы (%d):</b>\n\n", len(purchase.Channels))
for i, ch := range purchase.Channels { for i, ch := range purchase.Channels {
// Определяем статус emoji // Определяем статус
statusEmoji := "📌" statusSymbol := ""
switch ch.Status { switch ch.Status {
case "planned": case "planned":
statusEmoji = "📝" statusSymbol = ""
case "approved": case "approved":
statusEmoji = "" statusSymbol = ""
case "rejected": case "rejected":
statusEmoji = "" statusSymbol = ""
case "in_progress": case "in_progress":
statusEmoji = "🔄" statusSymbol = ""
case "completed": case "completed":
statusEmoji = "✔️" statusSymbol = ""
} }
// Название канала // Название канала
@@ -99,16 +102,16 @@ func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt str
channelName = "@" + *ch.Channel.Username channelName = "@" + *ch.Channel.Username
} }
text += fmt.Sprintf("%d. %s <b>%s</b>\n", i+1, statusEmoji, channelName) text += fmt.Sprintf("%d. %s <b>%s</b>\n", i+1, statusSymbol, channelName)
// Стоимость // Стоимость
if ch.PlannedCost != nil { if ch.PlannedCost != nil {
text += fmt.Sprintf(" 💰 Стоимость: %.0f₽\n", *ch.PlannedCost) text += fmt.Sprintf(" Стоимость: %.0f₽\n", *ch.PlannedCost)
} }
// Комментарий // Комментарий
if ch.Comment != nil && *ch.Comment != "" { if ch.Comment != nil && *ch.Comment != "" {
text += fmt.Sprintf(" 💬 %s\n", *ch.Comment) text += fmt.Sprintf(" %s\n", *ch.Comment)
} }
// Тип invite link // Тип invite link
@@ -116,7 +119,7 @@ func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt str
if ch.InviteLinkType == "approval" { if ch.InviteLinkType == "approval" {
inviteLinkTypeText = "с одобрением" inviteLinkTypeText = "с одобрением"
} }
text += fmt.Sprintf(" 🔗 Ссылка: %s\n", inviteLinkTypeText) text += fmt.Sprintf(" Ссылка: %s\n", inviteLinkTypeText)
text += "\n" text += "\n"
} }

View File

@@ -12,6 +12,7 @@ import (
type Purchases struct { type Purchases struct {
WorkspaceID string WorkspaceID string
ProjectID string ProjectID string
ProjectTitle string
BackState bot.State BackState bot.State
} }
@@ -39,7 +40,12 @@ func (s *Purchases) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string)
return return
} }
text := "<b>📦 Закупы</b>\n\n" // Навигация: 2 последних уровня
navPath := "… Закупы"
if s.ProjectTitle != "" {
navPath = fmt.Sprintf("… %s Закупы", s.ProjectTitle)
}
text := fmt.Sprintf("<i>%s</i>\n\n<b>Закупы</b>\n\n", navPath)
var buttons [][]echotron.InlineKeyboardButton var buttons [][]echotron.InlineKeyboardButton
@@ -47,24 +53,24 @@ func (s *Purchases) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string)
text += `Список закупов пуст. text += `Список закупов пуст.
<i>Закуп — это план размещения рекламы в каналах:</i> <i>Закуп — это план размещения рекламы в каналах:</i>
Выбор креатива Выбор креатива
Список каналов для размещения Список каналов для размещения
Планируемая стоимость Планируемая стоимость
Статус выполнения Статус выполнения
<i>Создайте первый закуп 👇</i>` Создайте первый закуп`
} else { } else {
text += "Управление закупами для проекта.\n\n" text += "Управление закупами для проекта.\n\n"
text += "<i>Выберите закуп для деталей:</i>\n\n" text += "Выберите закуп для деталей:\n\n"
for i, purchase := range purchases { for i, purchase := range purchases {
// Определяем статус emoji // Определяем статус
statusEmoji := "📌" statusSymbol := ""
switch purchase.Status { switch purchase.Status {
case "active": case "active":
statusEmoji = "🟢" statusSymbol = ""
case "archived": case "archived":
statusEmoji = "📦" statusSymbol = ""
} }
// Считаем каналы по статусам // Считаем каналы по статусам
@@ -77,7 +83,7 @@ func (s *Purchases) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string)
} }
buttonText := fmt.Sprintf("%s Закуп #%d (%d/%d каналов)", buttonText := fmt.Sprintf("%s Закуп #%d (%d/%d каналов)",
statusEmoji, statusSymbol,
i+1, i+1,
completedChannels, completedChannels,
totalChannels, totalChannels,
@@ -134,6 +140,7 @@ func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) {
b.SetState(&AddPurchase{ b.SetState(&AddPurchase{
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
BackState: s, BackState: s,
}, bot.EditMessage) }, bot.EditMessage)
@@ -144,6 +151,7 @@ func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) {
b.SetState(&PurchaseDetails{ b.SetState(&PurchaseDetails{
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
PurchaseID: purchaseID, PurchaseID: purchaseID,
BackState: s, BackState: s,
}, bot.NewMessage) }, bot.NewMessage)

View File

@@ -20,6 +20,7 @@ type PurchaseChannelInput struct {
type SelectChannelsForPurchase struct { type SelectChannelsForPurchase struct {
WorkspaceID string WorkspaceID string
ProjectID string ProjectID string
ProjectTitle string
CreativeID string CreativeID string
Channels []PurchaseChannelInput Channels []PurchaseChannelInput
BackState bot.State BackState bot.State
@@ -30,34 +31,35 @@ func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
} }
func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.RenderMode) { func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.RenderMode) {
text := "<b> Создание закупа</b>\n\n" text := "<i>… Создание Каналы</i>\n\n"
text += "<b>Шаг 2/2: Добавление каналов</b>\n\n" text += "<b>Создание закупа</b>\n\n"
text += "<b>Шаг 2/2:</b> Добавление каналов\n\n"
var buttons [][]echotron.InlineKeyboardButton var buttons [][]echotron.InlineKeyboardButton
if len(s.Channels) == 0 { if len(s.Channels) == 0 {
text += `Добавьте каналы для размещения рекламы. text += `Добавьте каналы для размещения рекламы
<i>Отправьте username канала (без @)</i> Отправьте username канала (без @)
Например: <code>channel_name</code> Например: <code>channel_name</code>
После добавления всех каналов нажмите "Создать закуп"` После добавления всех каналов нажмите <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))
for i, ch := range s.Channels { for i, ch := range s.Channels {
channelText := fmt.Sprintf(" %d. @%s", i+1, ch.Username) channelText := fmt.Sprintf(" %d. @%s", i+1, ch.Username)
if ch.PlannedCost != nil { if ch.PlannedCost != nil {
channelText += fmt.Sprintf(" (%.0f₽)", *ch.PlannedCost) channelText += fmt.Sprintf(" %.0f₽", *ch.PlannedCost)
} }
text += channelText + "\n" text += channelText + "\n"
} }
text += "\n<i>Добавьте еще каналы или создайте закуп</i>" text += "\nДобавьте еще каналы или создайте закуп"
// Кнопка создания закупа (доступна только если есть каналы) // Кнопка создания закупа (доступна только если есть каналы)
buttons = append(buttons, bot.Row( buttons = append(buttons, bot.Row(
bot.Button(" Создать закуп", "create"), bot.Button(" Создать закуп", "create"),
)) ))
} }
@@ -97,6 +99,7 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
b.SetState(&AddPurchase{ b.SetState(&AddPurchase{
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
BackState: s.BackState, BackState: s.BackState,
}, bot.EditMessage) }, bot.EditMessage)
@@ -159,16 +162,18 @@ func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
} }
// Показываем успешное создание // Показываем успешное создание
successText := fmt.Sprintf(`✅ <b>Закуп создан!</b> successText := fmt.Sprintf(`<i>… Создание Готово</i>
<b>ID:</b> %s <b>Закуп создан</b>
<b>Креатив:</b> %s
<b>Каналов:</b> %d
Закуп создан и готов к работе.`, purchase.ID, purchase.CreativeID, len(purchase.Channels)) ▪ <b>ID:</b> %s
▪ <b>Креатив:</b> %s
▪ <b>Каналов:</b> %d
Закуп создан и готов к работе`, purchase.ID, purchase.CreativeID, len(purchase.Channels))
b.Edit(successText, bot.Keyboard( b.Edit(successText, bot.Keyboard(
bot.Row(bot.Button(" Готово", "done")), bot.Row(bot.Button(" Готово", "done")),
)) ))
} }