package screens
import (
"context"
"fmt"
"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 := "Страница создания закупа и необязательные составляющие"
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("Дата размещения: %s%s", s.formatPlacementSummary(), s.formatPlacementDetails()),
fmt.Sprintf("Дата оплаты: %s", s.formatDateTime(s.PaymentDate)),
fmt.Sprintf("Стоимость: %s%s", s.formatCostSummary(), s.formatCostDetails()),
fmt.Sprintf("Стоимость до торга: %s%s", s.formatCostBeforeSummary(), s.formatCostBeforeDetails()),
fmt.Sprintf("Тип закупа: %s", s.formatText(s.PurchaseType)),
fmt.Sprintf("Формат: %s%s", s.formatFormatSummary(), s.formatFormatDetails()),
fmt.Sprintf("Комментарий: %s", 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 := "Дополнительно\n\n"
switch s.InputMode {
case "type":
text += "Выберите тип закупа"
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 += "Введите тип закупа\n\nНапример: вп"
case "cost_value":
text += "Ввод стоимости\n\nНапример: 15000"
if s.CurrentChannel != "" {
text += fmt.Sprintf("\n\nКанал: @%s", 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 += "Ввод стоимости до торга\n\nНапример: 20000"
if s.CurrentChannel != "" {
text += fmt.Sprintf("\n\nКанал: @%s", s.CurrentChannel)
}
case "format_select":
text = "Страница ввода формата размещения\n\n"
if s.CurrentChannel != "" {
text += fmt.Sprintf("Канал: @%s\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 += "Введите формат\n\nНапример: пост"
case "comment":
text += "Введите комментарий"
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 := "⚙ Переключить режим параметра\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("%s — по каналам\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
}
for _, placement := range placements.Placements {
creativePreview, err := b.Backend.BuildPlacementCreative(
context.Background(),
jwt,
b.Session.WorkspaceID,
s.ProjectID,
placement.ID,
)
if err != nil {
log.Error().Err(err).Msg("Failed to build placement creative")
continue
}
channelName := s.formatChannelName(placement)
header := fmt.Sprintf("Канал: %s", channelName)
b.SendMessage(header, b.ChatID, &echotron.MessageOptions{ParseMode: echotron.HTML})
s.sendCreativePreview(b, creativePreview)
}
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) sendCreativePreview(
b *bot.Bot,
creative *backend.CreativePreviewOutput,
) {
editor := CreativeEditorFields{
Text: &creative.Text,
MediaType: creative.MediaType,
MediaFileID: creative.MediaFileID,
}
if len(creative.Buttons) > 0 {
editor.Buttons = make([]InlineButton, 0, len(creative.Buttons))
for _, btn := range creative.Buttons {
editor.Buttons = append(editor.Buttons, InlineButton{
Text: btn.Text,
URL: btn.URL,
})
}
}
editor.SendCreativePreview(b)
}