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 { _, 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 } } 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 } }