Files
tgex-backend/tg_bot/screens/purchase_optional_details.go
2026-01-20 13:40:24 +03:00

1638 lines
44 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package screens
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
ui2 "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
type PurchaseOptionalDetails struct {
ProjectID string
ProjectTitle string
CreativeID string
CreativeTitle string
Channels []PurchaseChannelInput
PlacementDateTime *time.Time
PaymentDate *time.Time
CostType string
CostValue *float64
CostBeforeBargain *float64
PurchaseType string
Format string
Comment string
InputMode string
CurrentParam string
CurrentChannel string
ParamPage int
ReturnMode string
PlacementMode string
CostMode string
CostBeforeMode string
FormatMode string
PlacementByChannel map[string]*time.Time
CostByChannel map[string]CostEntry
CostBeforeByChannel map[string]*float64
FormatByChannel map[string]string
PlacementCopy *time.Time
CostCopy *CostEntry
CostBeforeCopy *float64
FormatCopy *string
ChannelEditMode string // "edit" или "copy"
BackState bot.State
}
type CostEntry struct {
Type string
Value *float64
}
func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
s.ensureDefaults()
if s.InputMode != "" {
if s.InputMode == "mode_select" {
s.renderModeSelect(b, mode)
return
}
if s.renderParamScreens(b, mode) {
return
}
s.renderInputPrompt(b, mode)
return
}
text := "<b>Страница создания закупа и необязательные составляющие</b>"
text += "\n\n"
text += s.formatOptionalSummary()
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, Row(
s.placementButton(),
s.costButton(),
))
rows = append(rows, Row(
s.paymentButton(),
s.typeButton(),
))
rows = append(rows, Row(
s.costBeforeButton(),
s.formatButton(),
))
rows = append(rows, Row(
s.commentButton(),
))
// Кнопка переключения режима (только если несколько каналов)
if len(s.Channels) > 1 {
rows = append(rows, Row(s.globalModeButton()))
}
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...)
b.Render(text, keyboard, mode)
}
func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
s.ensureDefaults()
switch u.CallbackQuery.Data {
case "opt_datetime":
// Открываем редактор в текущем режиме
if s.PlacementMode == "per_channel" && len(s.Channels) > 1 {
s.InputMode = "placement_channels"
s.Enter(b, bot.EditMessage)
} else {
s.setParamMode("placement", "common")
s.openCommonEditor(b, "placement")
}
case "opt_payment_date":
b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
Title: "Дата оплаты",
Key: "payment_date",
IncludeTime: true,
Selected: s.PaymentDate,
BackState: s,
}), bot.EditMessage)
case "opt_cost":
// Открываем редактор в текущем режиме
if s.CostMode == "per_channel" && len(s.Channels) > 1 {
s.InputMode = "cost_channels"
s.Enter(b, bot.EditMessage)
} else {
s.setParamMode("cost", "common")
s.openCommonEditor(b, "cost")
}
case "opt_type":
s.InputMode = "type"
s.Enter(b, bot.EditMessage)
case "opt_format":
// Открываем редактор в текущем режиме
if s.FormatMode == "per_channel" && len(s.Channels) > 1 {
s.InputMode = "format_channels"
s.Enter(b, bot.EditMessage)
} else {
s.setParamMode("format", "common")
s.openCommonEditor(b, "format")
}
case "opt_comment":
s.InputMode = "comment"
s.Enter(b, bot.EditMessage)
case "delete_comment":
s.Comment = ""
s.Enter(b, bot.EditMessage)
case "format_custom":
s.InputMode = "format_custom"
s.Enter(b, bot.EditMessage)
case "cost_type_toggle":
s.toggleCostType()
s.Enter(b, bot.EditMessage)
case "cost_value":
s.InputMode = "cost_value"
s.Enter(b, bot.EditMessage)
case "cost_before":
// Открываем редактор в текущем режиме
if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 {
s.InputMode = "cost_before_channels"
s.Enter(b, bot.EditMessage)
} else {
s.setParamMode("cost_before", "common")
s.openCommonEditor(b, "cost_before")
}
case "delete_cost_before":
s.CostBeforeBargain = nil
s.Enter(b, bot.EditMessage)
case "back_to_optional":
s.InputMode = ""
s.ReturnMode = ""
s.CurrentChannel = ""
s.CurrentParam = ""
s.Enter(b, bot.EditMessage)
case "back_to_return":
if s.ReturnMode != "" {
s.InputMode = s.ReturnMode
s.ReturnMode = ""
} else {
s.InputMode = ""
}
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
case "back":
b.SetState(&SelectChannelsForPurchase{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle,
Channels: s.Channels,
BackState: s.BackState,
}, bot.EditMessage)
case "next":
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
return
}
s.createPurchase(b, jwt)
case "done":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
default:
if u.CallbackQuery.Data == "noop" {
// Пустая кнопка - ничего не делаем
return
}
if s.handleParamCallback(b, u.CallbackQuery.Data) {
return
}
if u.CallbackQuery.Data == "toggle_global_mode" {
// Открываем меню выбора параметра для переключения режима
s.InputMode = "mode_select"
s.Enter(b, bot.EditMessage)
return
}
if strings.HasPrefix(u.CallbackQuery.Data, "channel_mode:") {
// Переключаем режим редактирования каналов
mode := strings.TrimPrefix(u.CallbackQuery.Data, "channel_mode:")
s.ChannelEditMode = mode
s.Enter(b, bot.EditMessage)
return
}
if strings.HasPrefix(u.CallbackQuery.Data, "toggle_param_mode:") {
// Переключаем режим конкретного параметра
param := strings.TrimPrefix(u.CallbackQuery.Data, "toggle_param_mode:")
currentMode := s.paramMode(param)
if currentMode == "common" {
s.setParamMode(param, "per_channel")
} else {
s.setParamMode(param, "common")
}
// Остаемся в меню выбора режима
s.Enter(b, bot.EditMessage)
return
}
if strings.HasPrefix(u.CallbackQuery.Data, "type:") {
value := strings.TrimPrefix(u.CallbackQuery.Data, "type:")
if value == "custom" {
s.InputMode = "type_custom"
s.Enter(b, bot.EditMessage)
return
}
// Нормализуем значение для БД
switch value {
case "self_promo":
s.PurchaseType = "самопиар"
case "standard":
s.PurchaseType = "стандарт"
}
s.InputMode = ""
s.Enter(b, bot.EditMessage)
return
}
if strings.HasPrefix(u.CallbackQuery.Data, "format:") {
value := strings.TrimPrefix(u.CallbackQuery.Data, "format:")
s.setFormatValue(value)
if s.ReturnMode != "" {
s.InputMode = s.ReturnMode
s.ReturnMode = ""
} else {
s.InputMode = ""
}
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
return
}
s.Enter(b, bot.EditMessage)
}
}
func (s *PurchaseOptionalDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
if s.InputMode == "" || u.Message == nil || u.Message.Text == "" {
return
}
s.ensureDefaults()
text := strings.TrimSpace(u.Message.Text)
if text == "" {
return
}
switch s.InputMode {
case "type_custom":
s.PurchaseType = text
case "format_custom":
s.setFormatValue(text)
case "comment":
s.Comment = text
case "cost_value":
value, err := strconv.ParseFloat(strings.ReplaceAll(text, ",", "."), 64)
if err != nil {
s.renderInputPrompt(b, bot.EditMessage)
return
}
s.setCostValue(value)
case "cost_before_value":
value, err := strconv.ParseFloat(strings.ReplaceAll(text, ",", "."), 64)
if err != nil {
s.renderInputPrompt(b, bot.EditMessage)
return
}
s.setCostBeforeValue(value)
}
if s.ReturnMode != "" {
s.InputMode = s.ReturnMode
s.ReturnMode = ""
} else {
s.InputMode = ""
}
s.CurrentChannel = ""
s.Enter(b, bot.NewMessage)
}
func (s *PurchaseOptionalDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PurchaseOptionalDetails) Exit() {}
func (s *PurchaseOptionalDetails) SetDateTimeSelection(key string, value time.Time) {
switch key {
case "placement_datetime":
s.PlacementDateTime = &value
case "payment_date":
s.PaymentDate = &value
default:
if strings.HasPrefix(key, "placement_datetime:") {
username := strings.TrimPrefix(key, "placement_datetime:")
s.ensureDefaults()
s.PlacementByChannel[username] = &value
}
}
}
func (s *PurchaseOptionalDetails) formatOptionalSummary() string {
lines := []string{
fmt.Sprintf("Дата размещения: <b>%s</b>%s", s.formatPlacementSummary(), s.formatPlacementDetails()),
fmt.Sprintf("Дата оплаты: <b>%s</b>", s.formatDateTime(s.PaymentDate)),
fmt.Sprintf("Стоимость: <b>%s</b>%s", s.formatCostSummary(), s.formatCostDetails()),
fmt.Sprintf("Стоимость до торга: <b>%s</b>%s", s.formatCostBeforeSummary(), s.formatCostBeforeDetails()),
fmt.Sprintf("Тип закупа: <b>%s</b>", s.formatText(s.PurchaseType)),
fmt.Sprintf("Формат: <b>%s</b>%s", s.formatFormatSummary(), s.formatFormatDetails()),
fmt.Sprintf("Комментарий: <b>%s</b>", s.formatText(s.Comment)),
}
return strings.Join(lines, "\n")
}
func (s *PurchaseOptionalDetails) formatDateTime(value *time.Time) string {
if value == nil {
return "—"
}
local := value.In(ui2.MskLocation)
return fmt.Sprintf("%s %02d %s %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()), local.Format("15:04"))
}
func (s *PurchaseOptionalDetails) formatText(value string) string {
if value == "" {
return "—"
}
return value
}
func (s *PurchaseOptionalDetails) formatCostValue() string {
if s.CostValue == nil {
return "—"
}
return fmt.Sprintf("%s %.0f₽", s.costTypeLabel(), *s.CostValue)
}
func (s *PurchaseOptionalDetails) formatCostBefore() string {
if s.CostBeforeBargain == nil {
return "—"
}
return fmt.Sprintf("%.0f₽", *s.CostBeforeBargain)
}
func (s *PurchaseOptionalDetails) costTypeLabel() string {
if s.CostType == "cpm" {
return "СРМ"
}
return "Фикс"
}
func (s *PurchaseOptionalDetails) placementButton() echotron.InlineKeyboardButton {
icon := ""
if s.hasPlacementValue() {
icon = "✎"
}
modeIcon := ""
if len(s.Channels) > 1 && s.PlacementMode == "per_channel" {
modeIcon = " 👥"
}
return Button(fmt.Sprintf("%s Дата размещения%s", icon, modeIcon), "opt_datetime")
}
func (s *PurchaseOptionalDetails) paymentButton() echotron.InlineKeyboardButton {
if s.PaymentDate == nil {
return Button(" Дата оплаты", "opt_payment_date")
}
return Button("✎ Дата оплаты", "opt_payment_date")
}
func (s *PurchaseOptionalDetails) costButton() echotron.InlineKeyboardButton {
icon := ""
if s.hasCostValue() {
icon = "✎"
}
modeIcon := ""
if len(s.Channels) > 1 && s.CostMode == "per_channel" {
modeIcon = " 👥"
}
return Button(fmt.Sprintf("%s Стоимость%s", icon, modeIcon), "opt_cost")
}
func (s *PurchaseOptionalDetails) costBeforeButton() echotron.InlineKeyboardButton {
icon := ""
if s.hasCostBeforeValue() {
icon = "✎"
}
modeIcon := ""
if len(s.Channels) > 1 && s.CostBeforeMode == "per_channel" {
modeIcon = " 👥"
}
return Button(fmt.Sprintf("%s До торга%s", icon, modeIcon), "cost_before")
}
func (s *PurchaseOptionalDetails) typeButton() echotron.InlineKeyboardButton {
if s.PurchaseType == "" {
return Button(" Тип", "opt_type")
}
return Button("✎ Тип", "opt_type")
}
func (s *PurchaseOptionalDetails) formatButton() echotron.InlineKeyboardButton {
icon := ""
if s.hasFormatValue() {
icon = "✎"
}
modeIcon := ""
if len(s.Channels) > 1 && s.FormatMode == "per_channel" {
modeIcon = " 👥"
}
return Button(fmt.Sprintf("%s Формат%s", icon, modeIcon), "opt_format")
}
func (s *PurchaseOptionalDetails) commentButton() echotron.InlineKeyboardButton {
if s.Comment == "" {
return Button(" Комментарий", "opt_comment")
}
return Button("✎ Комментарий", "opt_comment")
}
func (s *PurchaseOptionalDetails) globalModeButton() echotron.InlineKeyboardButton {
return Button("⚙ Общие / По каналам", "toggle_global_mode")
}
func (s *PurchaseOptionalDetails) commentDeleteRow() []echotron.InlineKeyboardButton {
if s.Comment == "" {
return nil
}
return Row(Button("⌫ Удалить комментарий", "delete_comment"))
}
func (s *PurchaseOptionalDetails) costBeforeDeleteRow() []echotron.InlineKeyboardButton {
if s.CostBeforeBargain == nil || s.CostBeforeMode == "per_channel" {
return nil
}
return Row(Button("⌫ Удалить до торга", "delete_cost_before"))
}
func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderMode) {
text := "<b>Дополнительно</b>\n\n"
switch s.InputMode {
case "type":
text += "<b>Выберите тип закупа</b>"
keyboard := Keyboard(
Row(
Button("Самопиар", "type:self_promo"),
Button("Стандарт", "type:standard"),
),
Row(Button("Другое", "type:custom")),
Row(Button("← Назад", "back_to_optional")),
)
if mode == bot.EditMessage {
b.Edit(text, keyboard)
} else {
b.SendNew(text, keyboard)
}
return
case "type_custom":
text += "<b>Введите тип закупа</b>\n\nНапример: <code>вп</code>"
case "cost_value":
text += "<b>Ввод стоимости</b>\n\nНапример: <code>15000</code>"
if s.CurrentChannel != "" {
text += fmt.Sprintf("\n\nКанал: <b>@%s</b>", s.CurrentChannel)
}
typeLabel := s.costTypeLabelForCurrent()
keyboard := Keyboard(
Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_type_toggle")),
Row(Button("← Назад", s.backAction())),
)
if mode == bot.EditMessage {
b.Edit(text, keyboard)
} else {
b.SendNew(text, keyboard)
}
return
case "cost_before_value":
text += "<b>Ввод стоимости до торга</b>\n\nНапример: <code>20000</code>"
if s.CurrentChannel != "" {
text += fmt.Sprintf("\n\nКанал: <b>@%s</b>", s.CurrentChannel)
}
case "format_select":
text = "<i>Страница ввода формата размещения</i>\n\n"
if s.CurrentChannel != "" {
text += fmt.Sprintf("Канал: <b>@%s</b>\n\n", s.CurrentChannel)
}
text += "Выберите формат\n\n"
keyboard := Keyboard(
Row(
Button("1 / 24", "format:1 / 24"),
Button("1/48", "format:1/48"),
Button("1 / 72", "format:1 / 72"),
),
Row(
Button("1 / (7 дней)", "format:1 / (7 дней)"),
Button("1 / (30 дней)", "format:1 / (30 дней)"),
Button("1 / (без удаления)", "format:1 / (без удаления)"),
),
Row(
Button("2 / 24", "format:2 / 24"),
Button("2/48", "format:2/48"),
Button("2/72", "format:2/72"),
),
Row(
Button("Назад", s.backAction()),
Button("Свой формат", "format_custom"),
),
)
if mode == bot.EditMessage {
b.Edit(text, keyboard)
} else {
b.SendNew(text, keyboard)
}
return
case "format_custom":
text += "<b>Введите формат</b>\n\nНапример: <code>пост</code>"
case "comment":
text += "<b>Введите комментарий</b>"
default:
s.InputMode = ""
s.Enter(b, mode)
return
}
keyboard := Keyboard(
Row(Button("← Назад", s.backAction())),
)
if mode == bot.EditMessage {
b.Edit(text, keyboard)
} else {
b.SendNew(text, keyboard)
}
}
func (s *PurchaseOptionalDetails) renderModeSelect(b *bot.Bot, mode bot.RenderMode) {
text := "<b>⚙ Переключить режим параметра</b>\n\n"
// Показываем текущую информацию о параметрах
text += s.formatOptionalSummary()
text += "\n\n"
text += "Выберите параметр для изменения режима:\n\n"
var rows [][]echotron.InlineKeyboardButton
// Дата размещения
placementLabel := "Дата размещения: "
if s.PlacementMode == "per_channel" {
placementLabel += "для каждого"
} else {
placementLabel += "общий"
}
rows = append(rows, Row(Button(placementLabel, "toggle_param_mode:placement")))
// Стоимость
costLabel := "Стоимость: "
if s.CostMode == "per_channel" {
costLabel += "для каждого"
} else {
costLabel += "общий"
}
rows = append(rows, Row(Button(costLabel, "toggle_param_mode:cost")))
// До торга
costBeforeLabel := "До торга: "
if s.CostBeforeMode == "per_channel" {
costBeforeLabel += "для каждого"
} else {
costBeforeLabel += "общий"
}
rows = append(rows, Row(Button(costBeforeLabel, "toggle_param_mode:cost_before")))
// Формат
formatLabel := "Формат: "
if s.FormatMode == "per_channel" {
formatLabel += "для каждого"
} else {
formatLabel += "общий"
}
rows = append(rows, Row(Button(formatLabel, "toggle_param_mode:format")))
rows = append(rows, Row(Button("← Назад", "back_to_optional")))
b.Render(text, Keyboard(rows...), mode)
}
func (s *PurchaseOptionalDetails) renderParamScreens(b *bot.Bot, mode bot.RenderMode) bool {
switch s.InputMode {
case "placement_channels":
s.renderParamChannels(b, mode, "placement")
return true
case "cost_channels":
s.renderParamChannels(b, mode, "cost")
return true
case "cost_before_channels":
s.renderParamChannels(b, mode, "cost_before")
return true
case "format_channels":
s.renderParamChannels(b, mode, "format")
return true
default:
return false
}
}
func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.RenderMode, param string) {
s.CurrentParam = param
text := fmt.Sprintf("<b>%s — по каналам</b>\n\n", s.paramTitle(param))
text += s.renderParamChannelSummary(param) + "\n\n"
const perPage = 5
total := len(s.Channels)
if total == 0 {
text += "\nКаналы не выбраны"
b.Render(text, Keyboard(Row(Button("← Назад", "back_to_optional"))), mode)
return
}
if s.ParamPage*perPage >= total {
s.ParamPage = 0
}
start, end := ui2.GetPageBounds(s.ParamPage, perPage, total)
// Устанавливаем режим по умолчанию
if s.ChannelEditMode == "" {
s.ChannelEditMode = "edit"
}
var rows [][]echotron.InlineKeyboardButton
// Кнопки переключения режима
editLabel := "Редактирование"
copyLabel := "Копирование"
if s.ChannelEditMode == "edit" {
editLabel = "● " + editLabel
} else {
copyLabel = "● " + copyLabel
}
rows = append(rows, Row(
Button(editLabel, "channel_mode:edit"),
Button(copyLabel, "channel_mode:copy"),
))
// Список каналов с кнопками в зависимости от режима
for i := start; i < end; i++ {
label := fmt.Sprintf("%d", i+1)
channelUsername := s.Channels[i].Username
if s.ChannelEditMode == "copy" {
// Режим копирования
var channelRow []echotron.InlineKeyboardButton
channelRow = append(channelRow, Button(label, fmt.Sprintf("param_edit:%s:%d", param, i)))
channelRow = append(channelRow, Button("⧉", fmt.Sprintf("param_copy:%s:%d", param, i)))
// Кнопка вставки - только если есть данные в буфере
if s.hasCopyBuffer(param) {
channelRow = append(channelRow, Button("⇲", fmt.Sprintf("param_paste:%s:%d", param, i)))
} else {
channelRow = append(channelRow, Button(" ", "noop"))
}
// Кнопка удаления - только если есть данные у канала
if s.hasChannelValue(param, channelUsername) {
channelRow = append(channelRow, Button("⌫", fmt.Sprintf("param_clear:%s:%d", param, i)))
} else {
channelRow = append(channelRow, Button(" ", "noop"))
}
rows = append(rows, channelRow)
} else {
// Обычный режим редактирования
var channelRow []echotron.InlineKeyboardButton
channelRow = append(channelRow, Button(label, fmt.Sprintf("param_edit:%s:%d", param, i)))
// Кнопка "Добавить" или "Изменить" в зависимости от наличия данных
hasValue := s.hasChannelValue(param, channelUsername)
if hasValue {
channelRow = append(channelRow, Button("Изменить", fmt.Sprintf("param_edit:%s:%d", param, i)))
} else {
channelRow = append(channelRow, Button("Добавить", fmt.Sprintf("param_edit:%s:%d", param, i)))
}
// Кнопка удаления - только если есть данные у канала
if hasValue {
channelRow = append(channelRow, Button("⌫", fmt.Sprintf("param_clear:%s:%d", param, i)))
} else {
channelRow = append(channelRow, Button(" ", "noop"))
}
rows = append(rows, channelRow)
}
}
pages := ui2.CalculatePages(total, perPage)
if navRow := ui2.BuildNavigationRow(ui2.PaginationConfig{
CurrentPage: s.ParamPage,
TotalPages: pages,
}); navRow != nil {
rows = append(rows, navRow)
}
rows = append(rows, Row(
Button("← Назад", fmt.Sprintf("param_back:%s", param)),
))
b.Render(text, Keyboard(rows...), mode)
}
func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string {
var lines []string
for i, ch := range s.Channels {
value := s.formatParamValue(param, ch.Username)
lines = append(lines, fmt.Sprintf("%d. @%s — %s", i+1, ch.Username, value))
}
return strings.Join(lines, "\n")
}
func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) bool {
switch {
case strings.HasPrefix(data, "param_edit:"):
param, index, ok := s.parseParamIndex(data, "param_edit:")
if !ok {
s.Enter(b, bot.EditMessage)
return true
}
if index >= 0 && index < len(s.Channels) {
s.CurrentParam = param
s.CurrentChannel = s.Channels[index].Username
s.openChannelEditor(b, param, s.CurrentChannel)
return true
}
s.Enter(b, bot.EditMessage)
return true
case strings.HasPrefix(data, "param_copy:"):
param, index, ok := s.parseParamIndex(data, "param_copy:")
if !ok {
s.Enter(b, bot.EditMessage)
return true
}
if index >= 0 && index < len(s.Channels) {
username := s.Channels[index].Username
s.copyParamValue(param, username)
s.Enter(b, bot.EditMessage)
return true
}
s.Enter(b, bot.EditMessage)
return true
case strings.HasPrefix(data, "param_paste:"):
param, index, ok := s.parseParamIndex(data, "param_paste:")
if !ok {
s.Enter(b, bot.EditMessage)
return true
}
if index >= 0 && index < len(s.Channels) {
username := s.Channels[index].Username
s.pasteParamValue(param, username)
s.Enter(b, bot.EditMessage)
return true
}
s.Enter(b, bot.EditMessage)
return true
case strings.HasPrefix(data, "param_clear:"):
param, index, ok := s.parseParamIndex(data, "param_clear:")
if !ok {
s.Enter(b, bot.EditMessage)
return true
}
if index >= 0 && index < len(s.Channels) {
username := s.Channels[index].Username
s.clearParamValue(param, username)
s.Enter(b, bot.EditMessage)
return true
}
s.Enter(b, bot.EditMessage)
return true
case data == "prev" && strings.HasSuffix(s.InputMode, "_channels"):
if s.ParamPage > 0 {
s.ParamPage--
}
s.Enter(b, bot.EditMessage)
return true
case data == "next" && strings.HasSuffix(s.InputMode, "_channels"):
s.ParamPage++
s.Enter(b, bot.EditMessage)
return true
case strings.HasPrefix(data, "param_back:"):
// Возвращаемся на главный экран
s.InputMode = ""
s.Enter(b, bot.EditMessage)
return true
}
return false
}
func (s *PurchaseOptionalDetails) parseParamIndex(data, prefix string) (string, int, bool) {
rest := strings.TrimPrefix(data, prefix)
parts := strings.Split(rest, ":")
if len(parts) != 2 {
return "", 0, false
}
param := parts[0]
index, err := strconv.Atoi(parts[1])
if err != nil {
return "", 0, false
}
return param, index, true
}
func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) {
switch param {
case "placement":
s.InputMode = ""
s.ReturnMode = ""
b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
Title: "Дата и время размещения",
Key: "placement_datetime",
IncludeTime: true,
Selected: s.PlacementDateTime,
BackState: s,
}), bot.EditMessage)
case "cost":
s.InputMode = "cost_value"
s.ReturnMode = ""
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
case "cost_before":
s.InputMode = "cost_before_value"
s.ReturnMode = ""
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
case "format":
s.InputMode = "format_select"
s.ReturnMode = ""
s.CurrentChannel = ""
s.Enter(b, bot.EditMessage)
default:
s.InputMode = ""
s.Enter(b, bot.EditMessage)
}
}
func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, username string) {
switch param {
case "placement":
s.InputMode = "placement_channels"
b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
Title: "Дата и время размещения",
Key: "placement_datetime:" + username,
IncludeTime: true,
Selected: s.PlacementByChannel[username],
BackState: s,
}), bot.EditMessage)
case "cost":
s.InputMode = "cost_value"
s.ReturnMode = "cost_channels"
s.CurrentChannel = username
s.Enter(b, bot.EditMessage)
case "cost_before":
s.InputMode = "cost_before_value"
s.ReturnMode = "cost_before_channels"
s.CurrentChannel = username
s.Enter(b, bot.EditMessage)
case "format":
s.InputMode = "format_select"
s.ReturnMode = "format_channels"
s.CurrentChannel = username
s.Enter(b, bot.EditMessage)
default:
s.InputMode = ""
s.Enter(b, bot.EditMessage)
}
}
func (s *PurchaseOptionalDetails) copyParamValue(param, username string) {
switch param {
case "placement":
s.PlacementCopy = s.PlacementByChannel[username]
case "cost":
entry := s.CostByChannel[username]
copied := entry
if entry.Value == nil {
copied.Value = nil
} else {
value := *entry.Value
copied.Value = &value
}
s.CostCopy = &copied
case "cost_before":
s.CostBeforeCopy = s.CostBeforeByChannel[username]
case "format":
if value, ok := s.FormatByChannel[username]; ok {
copied := value
s.FormatCopy = &copied
} else {
s.FormatCopy = nil
}
}
}
func (s *PurchaseOptionalDetails) pasteParamValue(param, username string) {
switch param {
case "placement":
if s.PlacementCopy == nil {
s.PlacementByChannel[username] = nil
return
}
value := s.PlacementCopy.In(ui2.MskLocation)
s.PlacementByChannel[username] = &value
case "cost":
if s.CostCopy == nil {
delete(s.CostByChannel, username)
return
}
copied := *s.CostCopy
if copied.Value != nil {
value := *copied.Value
copied.Value = &value
}
s.CostByChannel[username] = copied
case "cost_before":
if s.CostBeforeCopy == nil {
s.CostBeforeByChannel[username] = nil
return
}
value := *s.CostBeforeCopy
s.CostBeforeByChannel[username] = &value
case "format":
if s.FormatCopy == nil {
delete(s.FormatByChannel, username)
return
}
s.FormatByChannel[username] = *s.FormatCopy
}
}
func (s *PurchaseOptionalDetails) applyParamToAll(param string) {
switch param {
case "placement":
for _, ch := range s.Channels {
s.pasteParamValue(param, ch.Username)
}
case "cost":
for _, ch := range s.Channels {
s.pasteParamValue(param, ch.Username)
}
case "cost_before":
for _, ch := range s.Channels {
s.pasteParamValue(param, ch.Username)
}
case "format":
for _, ch := range s.Channels {
s.pasteParamValue(param, ch.Username)
}
}
}
func (s *PurchaseOptionalDetails) clearParamValue(param, username string) {
switch param {
case "placement":
s.PlacementByChannel[username] = nil
case "cost":
delete(s.CostByChannel, username)
case "cost_before":
s.CostBeforeByChannel[username] = nil
case "format":
delete(s.FormatByChannel, username)
}
}
func (s *PurchaseOptionalDetails) paramTitle(param string) string {
switch param {
case "placement":
return "Дата и время размещения"
case "cost":
return "Стоимость"
case "cost_before":
return "Стоимость до торга"
case "format":
return "Формат"
default:
return ""
}
}
func (s *PurchaseOptionalDetails) paramMode(param string) string {
switch param {
case "placement":
if s.PlacementMode == "" {
return "common"
}
return s.PlacementMode
case "cost":
if s.CostMode == "" {
return "common"
}
return s.CostMode
case "cost_before":
if s.CostBeforeMode == "" {
return "common"
}
return s.CostBeforeMode
case "format":
if s.FormatMode == "" {
return "common"
}
return s.FormatMode
default:
return "common"
}
}
func (s *PurchaseOptionalDetails) setParamMode(param, mode string) {
switch param {
case "placement":
s.PlacementMode = mode
case "cost":
s.CostMode = mode
case "cost_before":
s.CostBeforeMode = mode
case "format":
s.FormatMode = mode
}
}
func (s *PurchaseOptionalDetails) ensureDefaults() {
if s.PlacementMode == "" {
s.PlacementMode = "common"
}
if s.CostMode == "" {
s.CostMode = "common"
}
if s.CostBeforeMode == "" {
s.CostBeforeMode = "common"
}
if s.FormatMode == "" {
s.FormatMode = "common"
}
if s.PlacementByChannel == nil {
s.PlacementByChannel = make(map[string]*time.Time)
}
if s.CostByChannel == nil {
s.CostByChannel = make(map[string]CostEntry)
}
if s.CostBeforeByChannel == nil {
s.CostBeforeByChannel = make(map[string]*float64)
}
if s.FormatByChannel == nil {
s.FormatByChannel = make(map[string]string)
}
s.syncChannelMaps()
}
func (s *PurchaseOptionalDetails) syncChannelMaps() {
valid := make(map[string]struct{}, len(s.Channels))
for _, ch := range s.Channels {
valid[ch.Username] = struct{}{}
}
for key := range s.PlacementByChannel {
if _, ok := valid[key]; !ok {
delete(s.PlacementByChannel, key)
}
}
for key := range s.CostByChannel {
if _, ok := valid[key]; !ok {
delete(s.CostByChannel, key)
}
}
for key := range s.CostBeforeByChannel {
if _, ok := valid[key]; !ok {
delete(s.CostBeforeByChannel, key)
}
}
for key := range s.FormatByChannel {
if _, ok := valid[key]; !ok {
delete(s.FormatByChannel, key)
}
}
}
func (s *PurchaseOptionalDetails) formatPlacementSummary() string {
if s.PlacementMode == "per_channel" && len(s.Channels) > 1 {
return "для каждого"
}
return s.formatDateTime(s.PlacementDateTime)
}
func (s *PurchaseOptionalDetails) formatPlacementDetails() string {
if s.PlacementMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return "\n" + s.renderParamChannelSummary("placement")
}
func (s *PurchaseOptionalDetails) formatCostSummary() string {
if s.CostMode == "per_channel" && len(s.Channels) > 1 {
return "для каждого"
}
return s.formatCostValue()
}
func (s *PurchaseOptionalDetails) formatCostDetails() string {
if s.CostMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return "\n" + s.renderParamChannelSummary("cost")
}
func (s *PurchaseOptionalDetails) formatCostBeforeSummary() string {
if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 {
return "для каждого"
}
return s.formatCostBefore()
}
func (s *PurchaseOptionalDetails) formatCostBeforeDetails() string {
if s.CostBeforeMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return "\n" + s.renderParamChannelSummary("cost_before")
}
func (s *PurchaseOptionalDetails) formatFormatSummary() string {
if s.FormatMode == "per_channel" && len(s.Channels) > 1 {
return "для каждого"
}
return s.formatText(s.Format)
}
func (s *PurchaseOptionalDetails) formatFormatDetails() string {
if s.FormatMode != "per_channel" || len(s.Channels) <= 1 {
return ""
}
return "\n" + s.renderParamChannelSummary("format")
}
func (s *PurchaseOptionalDetails) formatParamValue(param, username string) string {
switch param {
case "placement":
value := s.PlacementByChannel[username]
return s.formatDateTime(value)
case "cost":
return s.formatCostForChannel(username)
case "cost_before":
return s.formatCostBeforeForChannel(username)
case "format":
if value, ok := s.FormatByChannel[username]; ok && value != "" {
return value
}
return "—"
default:
return "—"
}
}
func (s *PurchaseOptionalDetails) formatCostForChannel(username string) string {
entry, ok := s.CostByChannel[username]
if !ok || entry.Value == nil {
return "—"
}
label := s.costTypeLabelForEntry(entry)
return fmt.Sprintf("%s %.0f₽", label, *entry.Value)
}
func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(username string) string {
value, ok := s.CostBeforeByChannel[username]
if !ok || value == nil {
return "—"
}
return fmt.Sprintf("%.0f₽", *value)
}
func (s *PurchaseOptionalDetails) hasCopyBuffer(param string) bool {
switch param {
case "placement":
return s.PlacementCopy != nil
case "cost":
return s.CostCopy != nil
case "cost_before":
return s.CostBeforeCopy != nil
case "format":
return s.FormatCopy != nil
default:
return false
}
}
func (s *PurchaseOptionalDetails) hasChannelValue(param, username string) bool {
switch param {
case "placement":
value, ok := s.PlacementByChannel[username]
return ok && value != nil
case "cost":
entry, ok := s.CostByChannel[username]
return ok && entry.Value != nil
case "cost_before":
value, ok := s.CostBeforeByChannel[username]
return ok && value != nil
case "format":
value, ok := s.FormatByChannel[username]
return ok && value != ""
default:
return false
}
}
func (s *PurchaseOptionalDetails) hasPlacementValue() bool {
if s.PlacementMode == "per_channel" && len(s.Channels) > 1 {
for _, value := range s.PlacementByChannel {
if value != nil {
return true
}
}
return false
}
return s.PlacementDateTime != nil
}
func (s *PurchaseOptionalDetails) hasCostValue() bool {
if s.CostMode == "per_channel" && len(s.Channels) > 1 {
for _, entry := range s.CostByChannel {
if entry.Value != nil {
return true
}
}
return false
}
return s.CostValue != nil
}
func (s *PurchaseOptionalDetails) hasCostBeforeValue() bool {
if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 {
for _, value := range s.CostBeforeByChannel {
if value != nil {
return true
}
}
return false
}
return s.CostBeforeBargain != nil
}
func (s *PurchaseOptionalDetails) hasFormatValue() bool {
if s.FormatMode == "per_channel" && len(s.Channels) > 1 {
for _, value := range s.FormatByChannel {
if value != "" {
return true
}
}
return false
}
return s.Format != ""
}
func (s *PurchaseOptionalDetails) costTypeLabelForEntry(entry CostEntry) string {
if entry.Type == "cpm" {
return "СРМ"
}
return "Фикс"
}
func (s *PurchaseOptionalDetails) costTypeLabelForCurrent() string {
if s.CurrentChannel == "" {
return s.costTypeLabel()
}
entry, ok := s.CostByChannel[s.CurrentChannel]
if !ok || entry.Type == "" {
return s.costTypeLabel()
}
return s.costTypeLabelForEntry(entry)
}
func (s *PurchaseOptionalDetails) toggleCostType() {
if s.CurrentChannel == "" {
if s.costTypeLabel() == "СРМ" {
s.CostType = "fixed"
} else {
s.CostType = "cpm"
}
return
}
entry := s.CostByChannel[s.CurrentChannel]
if entry.Type == "" {
entry.Type = s.CostType
}
if s.costTypeLabelForEntry(entry) == "СРМ" {
entry.Type = "fixed"
} else {
entry.Type = "cpm"
}
s.CostByChannel[s.CurrentChannel] = entry
}
func (s *PurchaseOptionalDetails) setCostValue(value float64) {
if s.CurrentChannel == "" {
s.CostValue = &value
return
}
entry := s.CostByChannel[s.CurrentChannel]
entry.Value = &value
if entry.Type == "" {
entry.Type = s.CostType
}
s.CostByChannel[s.CurrentChannel] = entry
}
func (s *PurchaseOptionalDetails) setCostBeforeValue(value float64) {
if s.CurrentChannel == "" {
s.CostBeforeBargain = &value
return
}
s.CostBeforeByChannel[s.CurrentChannel] = &value
}
func (s *PurchaseOptionalDetails) setFormatValue(value string) {
if s.CurrentChannel == "" {
s.Format = value
return
}
s.FormatByChannel[s.CurrentChannel] = value
}
func (s *PurchaseOptionalDetails) backAction() string {
if s.ReturnMode != "" {
return "back_to_return"
}
return "back_to_optional"
}
func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) {
if len(s.Channels) == 0 {
b.SendNew("❌ Добавьте хотя бы один канал", Keyboard(
Row(Button("← Назад", "back")),
))
return
}
placementType, ok := s.normalizePurchaseType()
if !ok {
b.SendNew("❌ Тип закупа: используйте «самопиар» или «стандарт»", Keyboard(
Row(Button("← Назад", "back")),
))
return
}
placementDetails := s.buildPlacementDetails(placementType)
if placementDetails != nil && s.isPlacementDetailsEmpty(placementDetails) {
placementDetails = nil
}
apiChannels := make([]backend.CreatePlacementChannelInput, 0, len(s.Channels))
for _, ch := range s.Channels {
channelDetails := s.buildChannelDetails(ch.Username)
if channelDetails != nil && s.isChannelDetailsEmpty(channelDetails) {
channelDetails = nil
}
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
Username: ch.Username,
Comment: ch.Comment,
Details: channelDetails,
})
}
input := backend.CreatePlacementsInput{
CreativeID: s.CreativeID,
Channels: apiChannels,
Details: placementDetails,
}
placements, err := b.Backend.CreatePlacements(context.Background(), jwt, b.Session.WorkspaceID, s.ProjectID, input)
if err != nil {
log.Error().Err(err).Msg("Failed to create placements")
b.SendNew("❌ Не удалось создать размещения", Keyboard(
Row(Button("← Назад", "back")),
))
return
}
creative, err := b.Backend.GetCreative(context.Background(), jwt, b.Session.WorkspaceID, s.CreativeID)
if err != nil {
log.Error().Err(err).Msg("Failed to get creative")
creative = nil
}
if creative != nil {
for _, placement := range placements.Placements {
channelName := s.formatChannelName(placement)
header := fmt.Sprintf("<b>Канал:</b> %s", channelName)
b.SendMessage(header, b.ChatID, &echotron.MessageOptions{ParseMode: echotron.HTML})
s.sendCreativeWithInviteLink(b, creative, placement.InviteLink)
}
}
b.SetState(&Placements{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
BackState: s.BackState,
}, bot.NewMessage)
}
func (s *PurchaseOptionalDetails) buildPlacementDetails(placementType *string) *backend.PlacementDetails {
details := &backend.PlacementDetails{}
if s.PlacementMode != "per_channel" || len(s.Channels) <= 1 {
if s.PlacementDateTime != nil {
formatted := s.PlacementDateTime.Format(time.RFC3339)
details.PlacementAt = &formatted
}
}
if s.PaymentDate != nil {
formatted := s.PaymentDate.Format(time.RFC3339)
details.PaymentAt = &formatted
}
if s.CostMode != "per_channel" || len(s.Channels) <= 1 {
details.Cost = s.buildCostInfo(s.CostType, s.CostValue)
}
if s.CostBeforeMode != "per_channel" || len(s.Channels) <= 1 {
if s.CostBeforeBargain != nil {
details.CostBeforeBargain = s.CostBeforeBargain
}
}
if s.FormatMode != "per_channel" || len(s.Channels) <= 1 {
if s.Format != "" {
details.Format = &s.Format
}
}
if s.Comment != "" {
details.Comment = &s.Comment
}
if placementType != nil {
details.PlacementType = placementType
}
return details
}
func (s *PurchaseOptionalDetails) buildChannelDetails(username string) *backend.PlacementDetails {
if len(s.Channels) <= 1 {
return nil
}
details := &backend.PlacementDetails{}
if s.PlacementMode == "per_channel" {
if value, ok := s.PlacementByChannel[username]; ok && value != nil {
formatted := value.Format(time.RFC3339)
details.PlacementAt = &formatted
}
}
if s.CostMode == "per_channel" {
entry := s.CostByChannel[username]
details.Cost = s.buildCostInfo(entry.Type, entry.Value)
}
if s.CostBeforeMode == "per_channel" {
if value, ok := s.CostBeforeByChannel[username]; ok && value != nil {
details.CostBeforeBargain = value
}
}
if s.FormatMode == "per_channel" {
if value, ok := s.FormatByChannel[username]; ok && value != "" {
details.Format = &value
}
}
return details
}
func (s *PurchaseOptionalDetails) buildCostInfo(costType string, value *float64) *backend.CostInfo {
if value == nil {
return nil
}
normalized := "fixed"
if costType == "cpm" {
normalized = "cpm"
}
return &backend.CostInfo{
Type: normalized,
Value: *value,
}
}
func (s *PurchaseOptionalDetails) isPlacementDetailsEmpty(details *backend.PlacementDetails) bool {
return details.PlacementAt == nil &&
details.PaymentAt == nil &&
details.Cost == nil &&
details.CostBeforeBargain == nil &&
details.PlacementType == nil &&
details.Format == nil &&
details.Comment == nil
}
func (s *PurchaseOptionalDetails) isChannelDetailsEmpty(details *backend.PlacementDetails) bool {
return details.PlacementAt == nil &&
details.Cost == nil &&
details.CostBeforeBargain == nil &&
details.Format == nil
}
func (s *PurchaseOptionalDetails) normalizePurchaseType() (*string, bool) {
raw := strings.TrimSpace(s.PurchaseType)
if raw == "" {
return nil, true
}
normalized := strings.ToLower(raw)
normalized = strings.TrimSpace(strings.Trim(normalized, "."))
switch normalized {
case "самопиар", "само пиар", "self_promo", "self promo", "вп", "vp":
value := "self_promo"
return &value, true
case "стандарт", "standard":
value := "standard"
return &value, true
default:
return nil, false
}
}
func (s *PurchaseOptionalDetails) formatChannelName(placement backend.PlacementOutput) string {
if placement.Channel.Title != nil && *placement.Channel.Title != "" {
return *placement.Channel.Title
}
if placement.Channel.Username != nil && *placement.Channel.Username != "" {
return "@" + *placement.Channel.Username
}
return "Без названия"
}
func (s *PurchaseOptionalDetails) sendCreativeWithInviteLink(
b *bot.Bot,
creative *backend.Creative,
inviteLink string,
) {
log.Info().Str("invite_link", inviteLink).Msg("Sending creative with invite link")
linkedText := s.injectInviteLink(creative.Text, inviteLink)
editor := CreativeEditorFields{
Text: &linkedText,
MediaType: creative.MediaType,
MediaFileID: creative.MediaFileID,
}
if len(creative.Buttons) > 0 {
editor.Buttons = make([]InlineButton, 0, len(creative.Buttons))
for _, btn := range creative.Buttons {
url := btn.URL
if url == inviteLinkPlaceholder {
url = inviteLink
}
editor.Buttons = append(editor.Buttons, InlineButton{
Text: btn.Text,
URL: url,
})
}
}
editor.SendCreativePreview(b)
}
func (s *PurchaseOptionalDetails) injectInviteLink(text, inviteLink string) string {
if text == "" {
return text
}
re := regexp.MustCompile(`<a\s+class="tg-link">([^<]*)</a>`)
return re.ReplaceAllStringFunc(text, func(match string) string {
sub := re.FindStringSubmatch(match)
if len(sub) < 2 {
return match
}
inner := strings.TrimSpace(sub[1])
if inner == "" {
return fmt.Sprintf(`<a href="%s">%s</a>`, inviteLink, inviteLink)
}
return fmt.Sprintf(`<a href="%s">%s</a>`, inviteLink, sub[1])
})
}