правки

This commit is contained in:
Artem Tsyrulnikov
2026-02-02 11:59:37 +03:00
parent 52cd2ec1a0
commit 31b63e5a39
30 changed files with 710 additions and 126 deletions

View File

@@ -570,9 +570,13 @@ type CreateChannelResult struct {
}
type ProjectOutput struct {
ID string `json:"id"`
Status string `json:"status"`
Channel Channel `json:"channel"`
ID string `json:"id"`
TelegramID int64 `json:"telegram_id"`
Title string `json:"title"`
Username *string `json:"username"`
Status string `json:"status"`
PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"`
Channel Channel `json:"channel"`
}
type CreateChannelsOutput struct {
@@ -598,18 +602,18 @@ type PlacementDetails struct {
}
type PlacementOutput struct {
ID string `json:"id"`
Status string `json:"status"`
CreativeID *string `json:"creative_id,omitempty"`
CreativeName *string `json:"creative_name,omitempty"`
Comment *string `json:"comment,omitempty"`
InviteLink *string `json:"invite_link,omitempty"`
InviteLinkType string `json:"invite_link_type"`
Channel Channel `json:"channel"`
Project *ProjectOutput `json:"project"`
PlacementNumber int `json:"placement_number"`
Details *PlacementDetails `json:"details,omitempty"`
PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
ID string `json:"id"`
Status string `json:"status"`
CreativeID *string `json:"creative_id,omitempty"`
CreativeName *string `json:"creative_name,omitempty"`
Comment *string `json:"comment,omitempty"`
InviteLink *string `json:"invite_link,omitempty"`
InviteLinkType string `json:"invite_link_type"`
Channel Channel `json:"channel"`
Project *ProjectOutput `json:"project"`
ShortID string `json:"short_id"`
Details *PlacementDetails `json:"details,omitempty"`
PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
}
type PlacementPostOutput struct {

View File

@@ -55,7 +55,7 @@ const msgDownloadMediaError = `
const msgInviteLinkRequired = `
<b>❌ Ошибка валидации</b>
Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала!
Текст должен содержать <b>хотя бы одну</b> инвайт-ссылку вашего канала!
<b>Формат ссылки:</b>
<code>https://t.me/+xxx</code>

View File

@@ -16,9 +16,15 @@ import (
type CreativeDetails struct {
CreativeEditorFields // Встраивание общих полей
CreativeID string
ProjectID string
BackState bot.State
CreativeID string
ProjectID string
ProjectTitle string // Название проекта для сообщений
BackState bot.State
// View mode: открыты из режима просмотра
ViewMode bool // Открыты из CreativeView
ViewBackTo bot.State // Состояние для возврата (CreativeView)
// Флаг подтверждения удаления
confirmingDelete bool
@@ -89,7 +95,10 @@ func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
switch {
case data == "cancel", data == "back":
// Отмена - возвращаемся назад
if s.BackState != nil {
if s.ViewMode && s.ViewBackTo != nil {
// Если открыты из view mode, возвращаемся к просмотру
b.SetState(s.ViewBackTo, bot.EditMessage)
} else if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
@@ -355,7 +364,12 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
log.Info().Str("creative_id", s.CreativeID).Msg("Creative updated successfully")
// Возвращаемся назад
if s.BackState != nil {
if s.ViewMode && s.ViewBackTo != nil {
// Если открыты из view mode, возвращаемся к просмотру
b.SendNew("✅ Креатив успешно обновлен!", Keyboard())
b.SetState(s.ViewBackTo, bot.EditMessage)
} else if s.BackState != nil {
// Иначе возвращаемся к BackState
b.SendNew("✅ Креатив успешно обновлен!", Keyboard())
b.SetState(s.BackState, bot.EditMessage)
}

View File

@@ -0,0 +1,218 @@
package screens
import (
"context"
"fmt"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/rs/zerolog/log"
)
// CreativeView - экран просмотра креатива (read-only mode)
// Показывает превью креатива и кнопки действий: Редактировать, Тег, Закупить
type CreativeView struct {
CreativeEditorFields // Встраивание общих полей для отображения превью
CreativeID string
ProjectID string
// Данные проекта для передачи в AddPurchase
ProjectTitle string
ProjectTelegramID int64
ProjectUsername string
ProjectStatus string
BackState bot.State
}
const msgCreativeView = `
<b>📋 Просмотр креатива</b>
Выберите действие:
`
func (s *CreativeView) Enter(b *bot.Bot, mode bot.RenderMode) {
// Загружаем креатив
creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID)
if err != nil {
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative")
b.SendNew("<b>❌ Не удалось загрузить креатив</b>", Keyboard(
Row(Button("← Назад", "back")),
))
return
}
// Загружаем проект для получения данных
project, err := b.Backend.GetProject(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID)
if err != nil {
log.Error().Err(err).Str("project_id", s.ProjectID).Msg("Failed to get project")
} else {
s.ProjectTitle = project.Title
s.ProjectTelegramID = project.TelegramID
s.ProjectStatus = project.Status
if project.Username != nil {
s.ProjectUsername = *project.Username
}
}
// Заполняем поля креатива для отображения превью
s.Name = &creative.Name
if creative.Text != "" {
s.Text = &creative.Text
}
s.MediaItems = nil
if len(creative.MediaItems) > 0 {
for _, item := range creative.MediaItems {
s.MediaItems = append(s.MediaItems, MediaItem{
MediaType: item.MediaType,
MediaFileID: item.MediaFileID,
})
}
}
s.Buttons = nil
for _, btn := range creative.Buttons {
s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
}
if creative.Tag != "" {
s.Tag = &creative.Tag
}
// Отправляем превью креатива
s.SendCreativePreview(b)
// Отправляем панель управления с кнопками действий
s.showActionPanel(b)
}
func (s *CreativeView) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case data == "edit":
// Переход к редактированию креатива
b.SetState(&CreativeDetails{
CreativeID: s.CreativeID,
ProjectID: s.ProjectID,
ViewMode: true, // Флаг, что мы открылись из view mode
ViewBackTo: s, // Возврат к этому экрану после сохранения
ProjectTitle: s.ProjectTitle,
BackState: s.BackState,
}, bot.NewMessage)
case data == "toggle_tag":
// Переключение тега (testing <-> production)
s.toggleTag(b)
case data == "purchase_creative":
// Переход к созданию закупа с предвыбором проекта и креатива
creativeTitle := ""
if s.Name != nil {
creativeTitle = *s.Name
}
b.SetState(&AddPurchase{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus,
CreativeID: s.CreativeID,
CreativeTitle: creativeTitle,
BackState: s,
}, bot.NewMessage)
default:
log.Warn().Str("callback", data).Msg("Unknown callback in CreativeView")
}
}
func (s *CreativeView) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
func (s *CreativeView) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *CreativeView) Exit() {}
// showActionPanel показывает панель действий с кнопками
func (s *CreativeView) showActionPanel(b *bot.Bot) {
tagLabel := s.GetTagLabel()
purchaseLabel := "🛒 Закупить на креатив"
// Если креатив архивирован (в будущем), можно изменить кнопки
var buttons [][]echotron.InlineKeyboardButton
// Кнопка редактирования
buttons = append(buttons, Row(
Button("✏️ Редактировать", "edit"),
))
// Кнопка тега
buttons = append(buttons, Row(
Button(tagLabel, "toggle_tag"),
))
// Кнопка закупа
buttons = append(buttons, Row(
Button(purchaseLabel, "purchase_creative"),
))
// Кнопка назад
buttons = append(buttons, Row(
Button("← Назад", "back"),
))
s.ShowControlPanel(b, msgCreativeView, buttons)
}
// toggleTag переключает тег креатива (testing <-> production)
func (s *CreativeView) toggleTag(b *bot.Bot) {
newTag := "testing"
if s.Tag != nil && *s.Tag == "testing" {
newTag = "production"
}
// Обновляем тег через backend
input := backend.UpdateCreativeInput{
Tag: &newTag,
}
_, err := b.Backend.UpdateCreative(
context.Background(),
b.Session.JWT,
b.Session.WorkspaceID,
s.CreativeID,
input,
)
if err != nil {
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative tag")
s.ShowControlPanel(b, "<b>❌ Не удалось изменить тег</b>\n\nПопробуйте еще раз.", [][]echotron.InlineKeyboardButton{
Row(Button("← Назад", "back")),
Row(Button("↻ Попробовать снова", "toggle_tag")),
})
return
}
// Обновляем локальное значение
s.Tag = &newTag
// Показываем успех и обновляем панель
tagText := "🧪 Тестовый"
if newTag == "production" {
tagText = "🚀 Рабочий"
}
b.SendNew(fmt.Sprintf("✅ Тег изменен на «%s»", tagText), Keyboard())
// Обновляем панель
s.showActionPanel(b)
}

View File

@@ -120,10 +120,16 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
return
}
b.SetState(&CreativeDetails{
// Открываем CreativeView вместо CreativeDetails
b.SetState(&CreativeView{
CreativeID: creativeID,
ProjectID: s.ProjectID,
BackState: s,
// Данные проекта для передачи в AddPurchase
ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus,
BackState: s,
}, bot.NewMessage)
default:

View File

@@ -29,7 +29,7 @@ type Help struct{}
func (s *Help) Enter(b *bot.Bot, mode bot.RenderMode) {
keyboard := Keyboard(
Row(URLButton("Поддержка", "https://example.com")),
Row(URLButton("Поддержка", "https://t.me/SmartpostSupport")),
Row(Button("↩ В главное меню", "main_menu")),
)

View File

@@ -14,14 +14,22 @@ type MainMenu struct{}
const msgAbout = `
<i>Главное меню</i>
Smartpost — платформа для покупки и продажи рекламы без ЛС и ручных таблиц.
<pre>
За что отвечает каждая кнопка в этом окне:
• Внутренний счёт и безопасные сделки
• Фиксация цен, дат и форматов
• Автоматические отчёты
• Повторные закупки в пару кликов
<b>• Рабочее пространство</b> — Одно можно использовать для своих проектов, а другое, например, для проектов, где вы являетесь закупщиком.
Комиссия — 7%. Без скрытых платежей.
<b>• Мои проекты</b> — Управляйте вашими телеграм-каналами: добавляйте новые, привязывайте к ним креативы, создавайте закупы.
<b>• Размещения</b> — Просматривайте статистику по всем сделанным размещениям или создавайте новые (бот автоматически сформирует для вас креатив и заменит в нём пригласительную ссылку).
<b>• Веб-платформа</b> — Используйте функционал нашего сервиса на все 100%: планируйте размещения, просматривайте аналитику, следите за показателями проектов.
<b>• Помощь</b> — Изучите инструкции, как пользоваться нашим сервисом или напишите в поддержку, если возникли какие-то вопросы.
</pre>
Чтобы делать закупы, добавьте канал и креатив:
<b>/addchannel</b> — новый канал
`
func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
@@ -65,8 +73,8 @@ func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
keyboard := Keyboard(
Row(Button(fmt.Sprintf("Рабочее пространство: %s", workspaceName), "workspace_menu")),
Row(Button("Мои проекты", "my_projects"), URLButton("Поддержка", "https://example.com")),
Row(Button("Размещения", "placements"), URLButton("Дашборд", "https://example.com")),
Row(Button("Мои проекты", "my_projects"), URLButton("Поддержка", "https://t.me/SmartpostSupport")),
Row(Button("Размещения", "placements"), URLButton("Дашборд", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))),
)
b.Render(msg, keyboard, mode)

View File

@@ -57,9 +57,9 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) {
// Тип ссылки
text += fmt.Sprintf("🔓 <b>Тип ссылки:</b> %s\n", formatInviteLinkType(placement.InviteLinkType))
// Порядковый номер размещения
if placement.PlacementNumber > 0 {
text += fmt.Sprintf("🔢 <b>Размещение №%d</b> в этом канале\n", placement.PlacementNumber)
// Short ID
if placement.ShortID != "" {
text += fmt.Sprintf("🔢 <b>ID:</b> %s\n", placement.ShortID)
}
// Креатив
@@ -242,15 +242,63 @@ func formatDateTime(value string) string {
if value == "" {
return value
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed.Format("02.01.2006 15:04")
var parsed time.Time
var err error
// Try parsing with timezone
if parsed, err = time.Parse(time.RFC3339, value); err == nil {
return formatCompactDateTime(parsed)
}
if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
return parsed.Format("02.01.2006 15:04")
if parsed, err = time.Parse(time.RFC3339Nano, value); err == nil {
return formatCompactDateTime(parsed)
}
if parsed, err = time.Parse("2006-01-02T15:04:05", value); err == nil {
return formatCompactDateTime(parsed)
}
if parsed, err = time.Parse("2006-01-02", value); err == nil {
return formatCompactDateTime(parsed)
}
return value
}
func WeekdayName(day time.Weekday) string {
switch day {
case time.Monday:
return "Пн"
case time.Tuesday:
return "Вт"
case time.Wednesday:
return "Ср"
case time.Thursday:
return "Чт"
case time.Friday:
return "Пт"
case time.Saturday:
return "Сб"
case time.Sunday:
return "Вс"
default:
return ""
}
}
func MonthShort(month time.Month) string {
months := []string{
"янв", "фев", "мар", "апр", "май", "июн",
"июл", "авг", "сен", "окт", "ноя", "дек",
}
if int(month) < 1 || int(month) > len(months) {
return ""
}
return months[int(month)-1]
}
func formatCompactDateTime(value time.Time) string {
weekday := WeekdayName(value.Weekday())
month := MonthShort(value.Month())
return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04"))
}
func (s *PlacementDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return

View File

@@ -30,7 +30,7 @@ func (s *Placements) Enter(b *bot.Bot, mode bot.RenderMode) {
rows = append(rows, Row(Button(" Создать размещение", "add_purchase")))
rows = append(rows, Row(Button("Размещения", "placements_list")))
rows = append(rows, Row(URLButton("План закупов", "https://example.com")))
rows = append(rows, Row(URLButton("План закупов", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s/purchase-plans/%s", b.Session.WorkspaceID, s.ProjectID))))
rows = append(rows, Row(Button("← Назад", "back")))
keyboard := Keyboard(rows...)

View File

@@ -1,6 +1,8 @@
package screens
import (
"fmt"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
)
@@ -17,7 +19,7 @@ type PlatformLink struct{}
func (s *PlatformLink) Enter(b *bot.Bot, mode bot.RenderMode) {
keyboard := Keyboard(
Row(URLButton("Открыть платформу", "https://example.com")),
Row(URLButton("Открыть платформу", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))),
Row(Button("↩ В главное меню", "main_menu")),
)

View File

@@ -111,12 +111,6 @@ func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
s.inviteLinkTypeButton(),
))
if row := s.costBeforeDeleteRow(); row != nil {
rows = append(rows, row)
}
if row := s.commentDeleteRow(); row != nil {
rows = append(rows, row)
}
rows = append(rows, Row(Button("Назад", "back"), Button("Далее", "next")))
keyboard := Keyboard(rows...)
@@ -683,10 +677,21 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
}
typeLabel := s.costBeforeTypeLabelForCurrent()
keyboard := Keyboard(
Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")),
Row(Button("← Назад", s.backAction())),
)
var keyboard echotron.InlineKeyboardMarkup
// Показываем кнопку удаления только если есть значение и это не режим per-channel
if s.CurrentChannel == "" && s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil {
keyboard = Keyboard(
Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")),
Row(Button("⌫ Удалить до торга", "delete_cost_before")),
Row(Button("← Назад", s.backAction())),
)
} else {
keyboard = Keyboard(
Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")),
Row(Button("← Назад", s.backAction())),
)
}
if mode == bot.EditMessage {
b.Edit(text, keyboard)
@@ -750,6 +755,19 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
return
case "comment":
text += "<b>Введите комментарий</b>"
keyboard := Keyboard(Row(Button("← Назад", s.backAction())))
if s.Comment != "" {
keyboard = Keyboard(
Row(Button("⌫ Удалить комментарий", "delete_comment")),
Row(Button("← Назад", s.backAction())),
)
}
if mode == bot.EditMessage {
b.Edit(text, keyboard)
} else {
b.SendNew(text, keyboard)
}
return
default:
s.InputMode = ""
s.Enter(b, mode)